"""Azure DevOps service client. Uses two httpx.AsyncClient instances: - http_client: main AzDo REST API (dev.azure.com) - vsrm_http_client: VSRM release management API (vsrm.dev.azure.com) Both clients are injected via constructor for testability. """ from types import TracebackType from typing import Self import httpx from release_agent.models.build import ApprovalRecord, BuildStatus from release_agent.models.pipeline import PipelineInfo from release_agent.models.pr import PRInfo from release_agent.tools._http import build_auth_header, raise_for_status _API_VERSION = "7.1" class AzDoClient: """Client for the Azure DevOps REST API. Args: base_url: Main AzDo project API base URL. vsrm_base_url: VSRM release management API base URL. pat: Personal Access Token for authentication. http_client: Injected httpx.AsyncClient for the main API. vsrm_http_client: Injected httpx.AsyncClient for the VSRM API. """ def __init__( self, *, base_url: str, vsrm_base_url: str, pat: str, http_client: httpx.AsyncClient, vsrm_http_client: httpx.AsyncClient, ) -> None: self._base_url = base_url.rstrip("/") self._vsrm_base_url = vsrm_base_url.rstrip("/") self._auth = build_auth_header("", pat) self._http = http_client self._vsrm = vsrm_http_client async def close(self) -> None: """Close both underlying HTTP clients.""" await self._http.aclose() await self._vsrm.aclose() async def __aenter__(self) -> Self: return self async def __aexit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None, ) -> None: await self.close() # ------------------------------------------------------------------ # Pull requests # ------------------------------------------------------------------ async def get_pr(self, pr_id: int) -> PRInfo: """Fetch a pull request by ID. Args: pr_id: Numeric pull request ID. Returns: A PRInfo model populated from the API response. Raises: NotFoundError: If the PR does not exist. AuthenticationError: If authentication fails. ServiceError: For other HTTP errors. """ url = f"{self._base_url}/git/pullRequests/{pr_id}" response = await self._http.get(url, headers=self._auth, params={"api-version": _API_VERSION}) raise_for_status(response, service="azdo") data = response.json() return _parse_pr(data) async def list_active_prs(self, repo: str, target_branch: str) -> list[PRInfo]: """List active pull requests for a repository filtered by target branch. Args: repo: Repository name. target_branch: Target branch ref name (e.g. "refs/heads/develop"). Returns: List of PRInfo models for active PRs targeting the given branch. Raises: NotFoundError: If the repository does not exist. AuthenticationError: If authentication fails. ServiceError: For other HTTP errors. """ url = f"{self._base_url}/git/repositories/{repo}/pullRequests" response = await self._http.get( url, headers=self._auth, params={ "api-version": _API_VERSION, "status": "active", "targetRefName": target_branch, }, ) raise_for_status(response, service="azdo") data = response.json() return [_parse_pr(item) for item in data.get("value", [])] async def get_pr_diff(self, pr_id: int) -> str: """Return a diff-like string for the given pull request. Fetches the PR to determine the repository, then retrieves the diffs endpoint and formats a summary string suitable for code review. Args: pr_id: Numeric pull request ID. Returns: A text string describing changed files. Raises: NotFoundError: If the PR does not exist. ServiceError: For other HTTP errors. """ pr = await self.get_pr(pr_id) repo = pr.repo_name url = f"{self._base_url}/git/repositories/{repo}/pullRequests/{pr_id}/diffs" response = await self._http.get( url, headers=self._auth, params={"api-version": _API_VERSION, "baseVersionDescriptor.versionType": "commit"}, ) raise_for_status(response, service="azdo") data = response.json() return _format_diff(data) async def merge_pr(self, *, pr_id: int, last_merge_source_commit: str) -> bool: """Complete (merge) a pull request. Args: pr_id: Numeric pull request ID. last_merge_source_commit: The commit ID to use as the merge source. Returns: True on success. Raises: NotFoundError: If the PR does not exist. ServiceError: For other HTTP errors including merge conflicts. """ url = f"{self._base_url}/git/pullRequests/{pr_id}" payload = { "status": "completed", "lastMergeSourceCommit": {"commitId": last_merge_source_commit}, "completionOptions": {"mergeStrategy": "squash"}, } response = await self._http.patch( url, headers={**self._auth, "Content-Type": "application/json"}, json=payload, params={"api-version": _API_VERSION}, ) raise_for_status(response, service="azdo") return True async def create_pr( self, *, repo: str, source: str, target: str, title: str, description: str, ) -> dict: """Create a new pull request. Args: repo: Repository name. source: Source branch ref name (e.g. "refs/heads/release/v1.2.0"). target: Target branch ref name (e.g. "refs/heads/main"). title: PR title. description: PR description. Returns: Raw dict from the API response. Raises: ServiceError: For HTTP errors. """ url = f"{self._base_url}/git/repositories/{repo}/pullRequests" payload = { "sourceRefName": source, "targetRefName": target, "title": title, "description": description, } response = await self._http.post( url, headers={**self._auth, "Content-Type": "application/json"}, json=payload, params={"api-version": _API_VERSION}, ) raise_for_status(response, service="azdo") return response.json() # ------------------------------------------------------------------ # Pipelines # ------------------------------------------------------------------ async def list_build_pipelines(self, *, repo: str) -> list[PipelineInfo]: """List build pipelines for a repository. Args: repo: Repository name (used to filter pipelines). Returns: List of PipelineInfo models. Raises: ServiceError: For HTTP errors. """ url = f"{self._base_url}/pipelines" response = await self._http.get( url, headers=self._auth, params={"api-version": _API_VERSION}, ) raise_for_status(response, service="azdo") data = response.json() return [ PipelineInfo(id=item["id"], name=item["name"], repo=repo) for item in data.get("value", []) ] async def trigger_pipeline(self, *, pipeline_id: int, branch: str) -> dict: """Trigger a pipeline run. Args: pipeline_id: Numeric pipeline definition ID. branch: Branch ref to build (e.g. "refs/heads/main"). Returns: Raw dict from the API response containing the build/run ID. Raises: NotFoundError: If the pipeline does not exist. ServiceError: For other HTTP errors. """ url = f"{self._base_url}/pipelines/{pipeline_id}/runs" payload = {"resources": {"repositories": {"self": {"refName": branch}}}} response = await self._http.post( url, headers={**self._auth, "Content-Type": "application/json"}, json=payload, params={"api-version": _API_VERSION}, ) raise_for_status(response, service="azdo") return response.json() async def get_build_status(self, *, build_id: int) -> BuildStatus: """Get the status of a build. Args: build_id: Numeric build ID. Returns: BuildStatus dataclass with status, result, and build_url fields. Raises: NotFoundError: If the build does not exist. ServiceError: For other HTTP errors. """ url = f"{self._base_url}/build/builds/{build_id}" response = await self._http.get( url, headers=self._auth, params={"api-version": _API_VERSION}, ) raise_for_status(response, service="azdo") data = response.json() links = data.get("_links") or {} web_link = links.get("web") or {} build_url = web_link.get("href") or data.get("url") return BuildStatus( status=data["status"], result=data.get("result"), build_url=build_url, ) async def get_release_approvals(self, *, release_id: int) -> list[ApprovalRecord]: """Get pending approvals for a release. Args: release_id: Numeric release ID. Returns: List of ApprovalRecord dataclasses for this release. Raises: NotFoundError: If the release does not exist. ServiceError: For other HTTP errors. """ url = f"{self._vsrm_base_url}/release/approvals" response = await self._vsrm.get( url, headers=self._auth, params={"api-version": _API_VERSION, "releaseId": release_id}, ) raise_for_status(response, service="azdo") data = response.json() records: list[ApprovalRecord] = [] for item in data.get("value", []): env = item.get("releaseEnvironment") or {} rel = env.get("release") or {} records.append( ApprovalRecord( approval_id=str(item["id"]), stage_name=env.get("name", ""), status=item.get("status", "pending"), release_id=rel.get("id", release_id), ) ) return records async def get_latest_release(self, *, definition_id: int) -> dict: """Get the latest release for a release definition. Args: definition_id: Numeric release definition ID. Returns: Raw dict from the API response for the latest release, or an empty dict if no releases exist. Raises: NotFoundError: If the definition does not exist. ServiceError: For other HTTP errors. """ url = f"{self._vsrm_base_url}/release/releases" response = await self._vsrm.get( url, headers=self._auth, params={ "api-version": _API_VERSION, "definitionId": definition_id, "$top": 1, "$orderby": "id desc", }, ) raise_for_status(response, service="azdo") data = response.json() values = data.get("value", []) return values[0] if values else {} # ------------------------------------------------------------------ # Release approvals (VSRM) # ------------------------------------------------------------------ async def approve_release(self, *, approval_id: str, comment: str) -> dict: """Approve a release pipeline stage approval. Uses the VSRM API endpoint. Args: approval_id: The approval record ID. comment: Comment to attach to the approval. Returns: Raw dict from the API response. Raises: NotFoundError: If the approval does not exist. ServiceError: For other HTTP errors. """ url = f"{self._vsrm_base_url}/release/approvals/{approval_id}" payload = {"status": "approved", "comments": comment} response = await self._vsrm.patch( url, headers={**self._auth, "Content-Type": "application/json"}, json=payload, params={"api-version": _API_VERSION}, ) raise_for_status(response, service="azdo") return response.json() # ------------------------------------------------------------------- # PR Comment methods # ------------------------------------------------------------------- async def add_pr_comment( self, *, repo: str, pr_id: int, content: str, ) -> dict: """Add a general comment thread to a PR (not file-specific). Args: repo: Repository name. pr_id: Pull request ID. content: Markdown content for the comment. Returns: Raw dict from the API response. """ url = f"{self._base_url}/git/repositories/{repo}/pullRequests/{pr_id}/threads" payload = { "comments": [{"parentCommentId": 0, "content": content, "commentType": 1}], "status": "active", } response = await self._http.post( url, headers={**self._auth, "Content-Type": "application/json"}, json=payload, params={"api-version": _API_VERSION}, ) raise_for_status(response, service="azdo") return response.json() async def add_pr_inline_comment( self, *, repo: str, pr_id: int, content: str, file_path: str, line_start: int, line_end: int | None = None, ) -> dict: """Add an inline comment thread to a specific file and line in a PR. Args: repo: Repository name. pr_id: Pull request ID. content: Markdown content for the comment. file_path: Path to the file (e.g., "/src/Handlers/InvoiceHandler.cs"). line_start: Starting line number. line_end: Ending line number (defaults to line_start). Returns: Raw dict from the API response. """ effective_end = line_end if line_end is not None else line_start url = f"{self._base_url}/git/repositories/{repo}/pullRequests/{pr_id}/threads" payload = { "comments": [{"parentCommentId": 0, "content": content, "commentType": 1}], "threadContext": { "filePath": file_path if file_path.startswith("/") else f"/{file_path}", "rightFileStart": {"line": line_start, "offset": 1}, "rightFileEnd": {"line": effective_end, "offset": 1}, }, "status": "active", } response = await self._http.post( url, headers={**self._auth, "Content-Type": "application/json"}, json=payload, params={"api-version": _API_VERSION}, ) raise_for_status(response, service="azdo") return response.json() # --------------------------------------------------------------------------- # Private parsing helpers # --------------------------------------------------------------------------- def _parse_pr(data: dict) -> PRInfo: """Map a raw AzDo PR API response to a PRInfo model.""" pr_id = str(data["pullRequestId"]) repo = data["repository"]["name"] branch = data.get("sourceRefName", "") url = data.get("url", "") if not url: repo_url = data["repository"].get("remoteUrl", "") url = f"{repo_url}/pullrequest/{pr_id}" return PRInfo( pr_id=pr_id, pr_url=url, repo_name=repo, branch=branch, pr_title=data.get("title", ""), pr_status=_map_pr_status(data.get("status", "active")), ) def _map_pr_status(raw: str) -> str: """Normalise AzDo PR status to a value accepted by PRInfo.""" mapping = {"active": "active", "completed": "completed", "abandoned": "abandoned"} return mapping.get(raw, "active") def _format_diff(data: dict) -> str: """Format the diffs API response as a text string.""" changes = data.get("changes", []) lines = [] for change in changes: item = change.get("item", {}) path = item.get("path", "unknown") change_type = change.get("changeType", "edit") lines.append(f"{change_type}: {path}") return "\n".join(lines)