"""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()