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.
514 lines
17 KiB
Python
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)
|