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.
This commit is contained in:
513
src/release_agent/tools/azdo.py
Normal file
513
src/release_agent/tools/azdo.py
Normal file
@@ -0,0 +1,513 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user