195 lines
8.5 KiB
Python
195 lines
8.5 KiB
Python
"""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"^(?P<base>https://[^/]+(?:/[^/]+)*?)/scm/(?P<project>[^/]+)/(?P<repo>[^/]+?)(?:\.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()
|