"""Pull Requests (→ Git-Workflow): anlegen, Status und Review-Kommentare lesen. Bitbucket Data Center: REST API 1.0, Authentifizierung mit dem HTTP Access Token der Quelle; Entwürfe ab Version 8.18. Für Git-Server ohne Pull-Request-Funktion wird nur der Branch gepusht. Angelegt und als bereit markiert wird nur auf Klick eines Menschen; die KI liest Kommentare nur. Die Übersicht „Repository“ listet offene Pull Requests. """ import re from dataclasses import dataclass, field from datetime import UTC, datetime from typing import Protocol from urllib.parse import urlsplit import httpx from app.data_sources.connectors.git import auth_header from app.data_sources.connectors.git_repo import GitError PROVIDERS = {"none": "Nur Branch pushen", "bitbucket_dc": "Bitbucket Data Center"} MAX_COMMENTS = 200 MAX_OPEN = 50 @dataclass(frozen=True) class PullRequest: id: str url: str state: str = "OPEN" # OPEN | MERGED | DECLINED draft: bool = False version: int = 0 reviewers: list[dict] = field(default_factory=list) # [{"name": ..., "status": APPROVED | NEEDS_WORK | UNAPPROVED}] # Für die Liste offener Pull Requests title: str = "" author: str = "" source: str = "" # Branch, aus dem gemergt wird target: str = "" updated: datetime | None = None comments: int = 0 @dataclass(frozen=True) class Comment: id: str author: str text: str created: datetime | None path: str = "" line: int | None = None reply_to: str = "" resolved: bool = False class PullRequestProvider(Protocol): def find(self, branch: str) -> PullRequest | None: ... def create(self, branch: str, target: str, title: str, description: str, draft: bool = False) -> PullRequest | None: ... def get(self, pr_id: str) -> PullRequest | None: ... def publish(self, pr_id: str, version: int) -> PullRequest | None: ... def comments(self, pr_id: str) -> list[Comment]: ... def open(self) -> list[PullRequest]: ... class NoPullRequests: def find(self, branch: str) -> PullRequest | None: return None def create(self, branch: str, target: str, title: str, description: str, draft: bool = False) -> PullRequest | None: return None def get(self, pr_id: str) -> PullRequest | None: return None def publish(self, pr_id: str, version: int) -> PullRequest | None: return None def comments(self, pr_id: str) -> list[Comment]: return [] def open(self) -> list[PullRequest]: return [] # https://host[/kontext]/scm/PROJ/repo.git BITBUCKET_URL = re.compile(r"^(?Phttps://[^/]+(?:/[^/]+)*?)/scm/(?P[^/]+)/(?P[^/]+?)(?:\.git)?/?$") class BitbucketDataCenter: def __init__(self, clone_url: str, token: str | None, timeout: float, transport: httpx.BaseTransport | None = None, username: str = ""): match = BITBUCKET_URL.match(clone_url) if not match: raise GitError("URL passt nicht zu Bitbucket Data Center (erwartet …/scm/PROJEKT/repo.git).") self.api = (f"{match['base']}/rest/api/1.0/projects/{match['project']}/repos/{match['repo']}" "/pull-requests") self.host = urlsplit(clone_url).hostname headers = {"Accept": "application/json"} | ({"Authorization": auth_header(token, username)} if token else {}) self.client = httpx.Client(headers=headers, timeout=timeout, transport=transport, follow_redirects=False) def find(self, branch: str) -> PullRequest | None: response = self.request("GET", params={"at": f"refs/heads/{branch}", "direction": "OUTGOING", "state": "OPEN"}) values = response.json().get("values", []) return self.parse(values[0]) if values else None def create(self, branch: str, target: str, title: str, description: str, draft: bool = False) -> PullRequest: body = {"title": title, "description": description, "draft": draft, "fromRef": {"id": f"refs/heads/{branch}"}, "toRef": {"id": f"refs/heads/{target}"}} return self.parse(self.request("POST", json=body).json()) def get(self, pr_id: str) -> PullRequest: return self.parse(self.request("GET", f"/{check_id(pr_id)}").json()) def publish(self, pr_id: str, version: int) -> PullRequest: """Entwurf als bereit zum Review markieren.""" return self.parse(self.request("PUT", f"/{check_id(pr_id)}", json={"version": version, "draft": False}).json()) def comments(self, pr_id: str) -> list[Comment]: """Kommentare samt Antworten, älteste zuerst; aus der Aktivitätenliste des Pull Requests.""" result: list[Comment] = [] start = 0 while len(result) < MAX_COMMENTS: data = self.request("GET", f"/{check_id(pr_id)}/activities", params={"start": start, "limit": 100}).json() for activity in data.get("values", []): if activity.get("action") == "COMMENTED" and activity.get("commentAction") == "ADDED": anchor = activity.get("commentAnchor") or {} flatten(activity.get("comment") or {}, anchor, "", result) if data.get("isLastPage", True): break start = data.get("nextPageStart", start + 100) return sorted(result, key=lambda c: c.created or datetime.min.replace(tzinfo=UTC))[:MAX_COMMENTS] def open(self) -> list[PullRequest]: """Offene Pull Requests des Repositorys, zuletzt geänderte zuerst (höchstens MAX_OPEN).""" data = self.request("GET", params={"state": "OPEN", "order": "NEWEST", "limit": MAX_OPEN}).json() return [self.parse(v) for v in data.get("values", [])[:MAX_OPEN]] def request(self, method: str, path: str = "", **kwargs) -> httpx.Response: try: response = self.client.request(method, self.api + path, **kwargs) except httpx.HTTPError as e: raise GitError(f"Bitbucket nicht erreichbar ({type(e).__name__}).") from e if response.status_code >= 400: try: errors = response.json().get("errors", []) message = errors[0].get("message", "") if errors else "" except ValueError: message = "" raise GitError(f"Bitbucket meldet {response.status_code}: {message[:200]}".rstrip(": ")) return response @staticmethod def parse(data: dict) -> PullRequest: links = data.get("links", {}).get("self", []) reviewers = [{"name": (r.get("user") or {}).get("displayName", ""), "status": r.get("status", "")} for r in data.get("reviewers", [])] updated = data.get("updatedDate") return PullRequest(id=str(data.get("id", "")), url=links[0].get("href", "") if links else "", state=data.get("state", "OPEN"), draft=bool(data.get("draft", False)), version=int(data.get("version", 0)), reviewers=reviewers, title=str(data.get("title", ""))[:300], author=((data.get("author") or {}).get("user") or {}).get("displayName", ""), source=(data.get("fromRef") or {}).get("displayId", ""), target=(data.get("toRef") or {}).get("displayId", ""), updated=datetime.fromtimestamp(updated / 1000, UTC) if isinstance(updated, int) else None, comments=int((data.get("properties") or {}).get("commentCount", 0) or 0)) def check_id(pr_id: str) -> str: if not str(pr_id).isdigit(): raise GitError("Ungültige Pull-Request-ID.") return str(pr_id) def flatten(comment: dict, anchor: dict, reply_to: str, into: list[Comment]) -> None: created = comment.get("createdDate") into.append(Comment( id=str(comment.get("id", "")), author=(comment.get("author") or {}).get("displayName", ""), text=comment.get("text", ""), created=datetime.fromtimestamp(created / 1000, UTC) if created else None, path=anchor.get("path", "") if not reply_to else "", line=anchor.get("line") if not reply_to else None, reply_to=reply_to, resolved=comment.get("threadResolved", False) or comment.get("state") == "RESOLVED")) for reply in comment.get("comments", []): flatten(reply, anchor, str(comment.get("id", "")), into) def provider(kind: str, clone_url: str, token: str | None, timeout: float, username: str = "") -> PullRequestProvider: if kind == "bitbucket_dc": return BitbucketDataCenter(clone_url, token, timeout, username=username) return NoPullRequests()