Files
billo-release-agent/src/release_agent/tools/azdo.py
Yaojia Wang f5c2733cfb feat: initial commit — Billo Release Agent (LangGraph)
LangGraph-based release automation agent with:
- PR discovery (webhook + polling)
- AI code review via Claude Code CLI (subscription-based)
- Auto-create Jira tickets for PRs without ticket ID
- Jira ticket lifecycle management (code review -> staging -> done)
- CI/CD pipeline trigger, polling, and approval gates
- Slack interactive messages with approval buttons
- Per-repo semantic versioning
- PostgreSQL persistence (threads, staging, releases)
- FastAPI API (webhooks, approvals, status, manual triggers)
- Docker Compose deployment

1069 tests, 95%+ coverage.
2026-03-24 17:38:23 +01:00

514 lines
17 KiB
Python

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