Files
billo-release-agent/tests/graph/test_pr_completed.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

957 lines
38 KiB
Python

"""Tests for graph/pr_completed.py node functions. Written FIRST (TDD RED phase).
Each node is an async function (state, config) -> dict.
Tests call nodes directly with a state dict and config dict — no graph compilation.
"""
from datetime import date, datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from release_agent.graph.dependencies import JsonFileStagingStore, ToolClients
from release_agent.graph.pr_completed import (
_post_review_to_pr,
add_jira_pr_link,
auto_create_ticket,
calculate_version,
evaluate_review,
fetch_pr_details,
interrupt_confirm_merge,
merge_pr_node,
move_jira_code_review,
move_jira_ready_for_stage,
notify_request_changes,
parse_webhook,
run_code_review,
update_staging,
build_pr_completed_graph,
)
from release_agent.models.review import ReviewIssue
from release_agent.models.jira import JiraIssue
from release_agent.models.pr import PRInfo
from release_agent.models.review import ReviewResult
from tests.graph.conftest import build_config, build_mock_clients
# ---------------------------------------------------------------------------
# Webhook payload fixtures
# ---------------------------------------------------------------------------
def _make_webhook_payload(
*,
repo_name: str = "my-repo",
pr_id: int = 42,
source_ref: str = "refs/heads/feature/ALLPOST-100_fix-bug",
target_ref: str = "refs/heads/main",
status: str = "completed",
title: str = "Fix: bug",
closed_date: str | None = "2025-01-15T10:00:00Z",
) -> dict:
# Uses snake_case keys to match WebhookPayload Pydantic model field names
return {
"subscription_id": "sub-1",
"event_type": "git.pullrequest.merged",
"resource": {
"repository": {
"id": "repo-id-1",
"name": repo_name,
"web_url": "https://dev.azure.com/org/proj/_git/my-repo",
},
"pull_request_id": pr_id,
"title": title,
"source_ref_name": source_ref,
"target_ref_name": target_ref,
"status": status,
"closed_date": closed_date,
},
}
def _make_pr_info(
*,
pr_id: str = "42",
repo_name: str = "my-repo",
branch: str = "refs/heads/feature/ALLPOST-100-fix-bug",
status: str = "completed",
) -> PRInfo:
return PRInfo(
pr_id=pr_id,
pr_url="https://dev.azure.com/org/proj/_git/my-repo/pullrequest/42",
repo_name=repo_name,
branch=branch,
pr_title="Fix: bug",
pr_status=status,
)
def _make_approve_review() -> dict:
return {
"verdict": "approve",
"summary": "Looks good",
"issues": [],
"has_blockers": False,
}
def _make_request_changes_review() -> dict:
return {
"verdict": "request_changes",
"summary": "Needs work",
"issues": [{"severity": "blocker", "description": "Missing tests"}],
"has_blockers": True,
}
# ---------------------------------------------------------------------------
# parse_webhook
# ---------------------------------------------------------------------------
class TestParseWebhook:
async def test_extracts_pr_info_from_payload(self) -> None:
state = {"webhook_payload": _make_webhook_payload()}
config = build_config()
result = await parse_webhook(state, config)
assert "pr_info" in result
pr = result["pr_info"]
assert pr["pr_id"] == "42"
assert pr["repo_name"] == "my-repo"
async def test_extracts_ticket_from_branch(self) -> None:
state = {"webhook_payload": _make_webhook_payload(
source_ref="refs/heads/feature/ALLPOST-100_fix-bug"
)}
config = build_config()
result = await parse_webhook(state, config)
assert result["ticket_id"] == "ALLPOST-100"
assert result["has_ticket"] is True
async def test_no_ticket_when_branch_has_none(self) -> None:
state = {"webhook_payload": _make_webhook_payload(
source_ref="refs/heads/bugfix/generic_fix"
)}
config = build_config()
result = await parse_webhook(state, config)
assert result["has_ticket"] is False
assert result["ticket_id"] is None
async def test_sets_repo_name(self) -> None:
state = {"webhook_payload": _make_webhook_payload(repo_name="backend-api")}
config = build_config()
result = await parse_webhook(state, config)
assert result["repo_name"] == "backend-api"
async def test_sets_pr_id_as_string(self) -> None:
state = {"webhook_payload": _make_webhook_payload(pr_id=99)}
config = build_config()
result = await parse_webhook(state, config)
assert result["pr_info"]["pr_id"] == "99"
async def test_invalid_payload_adds_error(self) -> None:
state = {"webhook_payload": {"bad": "data"}}
config = build_config()
result = await parse_webhook(state, config)
assert "errors" in result
assert len(result["errors"]) > 0
# ---------------------------------------------------------------------------
# fetch_pr_details
# ---------------------------------------------------------------------------
class TestFetchPrDetails:
async def test_fetches_pr_and_sets_pr_already_merged_false(self) -> None:
clients = build_mock_clients()
pr = _make_pr_info(status="active")
clients.azdo.get_pr = AsyncMock(return_value=pr)
clients.azdo.get_pr_diff = AsyncMock(return_value="edit: main.py")
config = build_config(clients)
state = {"pr_id": "42", "pr_info": {"pr_id": "42", "pr_status": "active"}}
result = await fetch_pr_details(state, config)
assert result["pr_already_merged"] is False
assert result["pr_diff"] == "edit: main.py"
async def test_sets_pr_already_merged_true_when_completed(self) -> None:
clients = build_mock_clients()
pr = _make_pr_info(status="completed")
clients.azdo.get_pr = AsyncMock(return_value=pr)
clients.azdo.get_pr_diff = AsyncMock(return_value="")
config = build_config(clients)
state = {"pr_id": "42", "pr_info": {"pr_id": "42", "pr_status": "completed"}}
result = await fetch_pr_details(state, config)
assert result["pr_already_merged"] is True
async def test_stores_last_merge_source_commit(self) -> None:
clients = build_mock_clients()
pr = _make_pr_info(status="active")
clients.azdo.get_pr = AsyncMock(return_value=pr)
clients.azdo.get_pr_diff = AsyncMock(return_value="edit: main.py")
config = build_config(clients)
state = {"pr_id": "42", "pr_info": {"pr_id": "42"}}
result = await fetch_pr_details(state, config)
# last_merge_source_commit may be None if pr doesn't have it, but key must be present
assert "last_merge_source_commit" in result
async def test_adds_error_on_service_failure(self) -> None:
from release_agent.exceptions import ServiceError
clients = build_mock_clients()
clients.azdo.get_pr = AsyncMock(side_effect=ServiceError(
service="azdo", status_code=500, detail="Server error"
))
config = build_config(clients)
state = {"pr_id": "42"}
result = await fetch_pr_details(state, config)
assert "errors" in result
assert len(result["errors"]) > 0
# ---------------------------------------------------------------------------
# move_jira_code_review
# ---------------------------------------------------------------------------
class TestMoveJiraCodeReview:
async def test_transitions_ticket_when_has_ticket(self) -> None:
clients = build_mock_clients()
clients.jira.transition_issue = AsyncMock(return_value=True)
config = build_config(clients)
state = {"ticket_id": "ALLPOST-100", "has_ticket": True}
result = await move_jira_code_review(state, config)
clients.jira.transition_issue.assert_called_once_with("ALLPOST-100", "code review")
assert result == {} or "messages" in result
async def test_skips_when_no_ticket(self) -> None:
clients = build_mock_clients()
clients.jira.transition_issue = AsyncMock(return_value=True)
config = build_config(clients)
state = {"has_ticket": False, "ticket_id": None}
result = await move_jira_code_review(state, config)
clients.jira.transition_issue.assert_not_called()
async def test_appends_error_on_jira_failure(self) -> None:
from release_agent.exceptions import ServiceError
clients = build_mock_clients()
clients.jira.transition_issue = AsyncMock(side_effect=ServiceError(
service="jira", status_code=500, detail="Jira down"
))
config = build_config(clients)
state = {"ticket_id": "ALLPOST-100", "has_ticket": True}
result = await move_jira_code_review(state, config)
assert "errors" in result
assert len(result["errors"]) > 0
# ---------------------------------------------------------------------------
# run_code_review
# ---------------------------------------------------------------------------
class TestRunCodeReview:
async def test_calls_reviewer_with_diff(self) -> None:
clients = build_mock_clients()
review = ReviewResult(verdict="approve", summary="LGTM", issues=())
clients.reviewer.review_pr = AsyncMock(return_value=review)
config = build_config(clients)
state = {
"pr_diff": "edit: main.py",
"pr_info": {"pr_title": "Fix: bug", "repo_name": "my-repo"},
}
result = await run_code_review(state, config)
clients.reviewer.review_pr.assert_called_once()
assert "review_result" in result
async def test_stores_review_result_as_dict(self) -> None:
clients = build_mock_clients()
review = ReviewResult(verdict="approve", summary="Clean code", issues=())
clients.reviewer.review_pr = AsyncMock(return_value=review)
config = build_config(clients)
state = {
"pr_diff": "edit: main.py",
"pr_info": {"pr_title": "Fix", "repo_name": "repo"},
}
result = await run_code_review(state, config)
assert result["review_result"]["verdict"] == "approve"
async def test_adds_error_on_reviewer_failure(self) -> None:
clients = build_mock_clients()
clients.reviewer.review_pr = AsyncMock(side_effect=Exception("API error"))
config = build_config(clients)
state = {
"pr_diff": "edit: main.py",
"pr_info": {"pr_title": "Fix", "repo_name": "repo"},
}
result = await run_code_review(state, config)
assert "errors" in result
# ---------------------------------------------------------------------------
# _post_review_to_pr
# ---------------------------------------------------------------------------
class TestPostReviewToPr:
async def test_posts_summary_comment(self) -> None:
clients = build_mock_clients()
clients.azdo.add_pr_comment = AsyncMock()
clients.azdo.add_pr_inline_comment = AsyncMock()
review = ReviewResult(verdict="approve", summary="LGTM", issues=())
await _post_review_to_pr(clients, "my-repo", 42, review)
clients.azdo.add_pr_comment.assert_called_once()
call_kwargs = clients.azdo.add_pr_comment.call_args
assert "APPROVE" in call_kwargs.kwargs["content"]
async def test_posts_inline_comment_for_issue_with_file_and_line(self) -> None:
clients = build_mock_clients()
clients.azdo.add_pr_comment = AsyncMock()
clients.azdo.add_pr_inline_comment = AsyncMock()
issue = ReviewIssue(
severity="error", description="Null check missing",
file_path="src/Foo.cs", line_start=42, suggestion="Add null guard",
)
review = ReviewResult(verdict="request_changes", summary="Issues", issues=(issue,))
await _post_review_to_pr(clients, "my-repo", 42, review)
clients.azdo.add_pr_inline_comment.assert_called_once()
call_kwargs = clients.azdo.add_pr_inline_comment.call_args.kwargs
assert call_kwargs["file_path"] == "src/Foo.cs"
assert call_kwargs["line_start"] == 42
assert "Null check missing" in call_kwargs["content"]
assert "Add null guard" in call_kwargs["content"]
async def test_skips_inline_for_issue_without_line(self) -> None:
clients = build_mock_clients()
clients.azdo.add_pr_comment = AsyncMock()
clients.azdo.add_pr_inline_comment = AsyncMock()
issue = ReviewIssue(severity="warning", description="Style issue", file_path="src/Foo.cs")
review = ReviewResult(verdict="approve", summary="OK", issues=(issue,))
await _post_review_to_pr(clients, "my-repo", 42, review)
clients.azdo.add_pr_inline_comment.assert_not_called()
async def test_skips_inline_for_issue_without_file(self) -> None:
clients = build_mock_clients()
clients.azdo.add_pr_comment = AsyncMock()
clients.azdo.add_pr_inline_comment = AsyncMock()
issue = ReviewIssue(severity="info", description="General note", line_start=10)
review = ReviewResult(verdict="approve", summary="OK", issues=(issue,))
await _post_review_to_pr(clients, "my-repo", 42, review)
clients.azdo.add_pr_inline_comment.assert_not_called()
async def test_inline_failure_does_not_prevent_summary(self) -> None:
clients = build_mock_clients()
clients.azdo.add_pr_comment = AsyncMock()
clients.azdo.add_pr_inline_comment = AsyncMock(side_effect=Exception("API error"))
issue = ReviewIssue(
severity="blocker", description="Critical", file_path="a.cs", line_start=1
)
review = ReviewResult(verdict="request_changes", summary="Bad", issues=(issue,))
await _post_review_to_pr(clients, "my-repo", 42, review)
# Summary should still be posted even though inline failed
clients.azdo.add_pr_comment.assert_called_once()
async def test_summary_failure_does_not_raise(self) -> None:
clients = build_mock_clients()
clients.azdo.add_pr_comment = AsyncMock(side_effect=Exception("Network error"))
clients.azdo.add_pr_inline_comment = AsyncMock()
review = ReviewResult(verdict="approve", summary="LGTM", issues=())
# Should not raise
await _post_review_to_pr(clients, "my-repo", 42, review)
async def test_summary_contains_issue_count(self) -> None:
clients = build_mock_clients()
clients.azdo.add_pr_comment = AsyncMock()
clients.azdo.add_pr_inline_comment = AsyncMock()
issues = (
ReviewIssue(severity="warning", description="Issue 1"),
ReviewIssue(severity="error", description="Issue 2"),
)
review = ReviewResult(verdict="request_changes", summary="Problems", issues=issues)
await _post_review_to_pr(clients, "my-repo", 42, review)
content = clients.azdo.add_pr_comment.call_args.kwargs["content"]
assert "2 issue(s)" in content
async def test_run_code_review_calls_post_review(self) -> None:
"""Integration: run_code_review posts comments when pr_id and repo_name present."""
clients = build_mock_clients()
review = ReviewResult(verdict="approve", summary="LGTM", issues=())
clients.reviewer.review_pr = AsyncMock(return_value=review)
clients.azdo.add_pr_comment = AsyncMock()
clients.azdo.add_pr_inline_comment = AsyncMock()
config = build_config(clients)
state = {
"pr_diff": "edit: main.py",
"pr_info": {"pr_id": "42", "pr_title": "Fix", "repo_name": "my-repo"},
}
await run_code_review(state, config)
clients.azdo.add_pr_comment.assert_called_once()
# ---------------------------------------------------------------------------
# evaluate_review
# ---------------------------------------------------------------------------
class TestEvaluateReview:
async def test_sets_review_approved_true_for_approve_verdict(self) -> None:
config = build_config()
state = {"review_result": _make_approve_review()}
result = await evaluate_review(state, config)
assert result["review_approved"] is True
async def test_sets_review_approved_false_for_request_changes(self) -> None:
config = build_config()
state = {"review_result": _make_request_changes_review()}
result = await evaluate_review(state, config)
assert result["review_approved"] is False
async def test_sets_false_when_review_result_missing(self) -> None:
config = build_config()
state = {}
result = await evaluate_review(state, config)
assert result["review_approved"] is False
async def test_sets_false_when_has_blockers(self) -> None:
config = build_config()
state = {
"review_result": {
"verdict": "approve",
"summary": "Approve with blocker?",
"issues": [{"severity": "blocker", "description": "Problem"}],
"has_blockers": True,
}
}
result = await evaluate_review(state, config)
assert result["review_approved"] is False
# ---------------------------------------------------------------------------
# interrupt_confirm_merge
# ---------------------------------------------------------------------------
class TestInterruptConfirmMerge:
async def test_calls_interrupt_with_summary_string(self) -> None:
config = build_config()
state = {
"pr_info": {"pr_id": "42", "pr_title": "Fix: bug", "repo_name": "my-repo"},
"review_result": {"summary": "LGTM"},
}
with patch("release_agent.graph.pr_completed.interrupt") as mock_interrupt:
mock_interrupt.return_value = "confirm"
await interrupt_confirm_merge(state, config)
mock_interrupt.assert_called_once()
call_arg = mock_interrupt.call_args[0][0]
assert isinstance(call_arg, str)
assert len(call_arg) > 0
async def test_interrupt_value_contains_pr_info(self) -> None:
config = build_config()
state = {
"pr_info": {"pr_id": "42", "pr_title": "Fix: auth bug", "repo_name": "backend"},
"review_result": {"summary": "All good"},
}
with patch("release_agent.graph.pr_completed.interrupt") as mock_interrupt:
mock_interrupt.return_value = "confirm"
await interrupt_confirm_merge(state, config)
call_arg = mock_interrupt.call_args[0][0]
assert "42" in call_arg or "Fix: auth bug" in call_arg or "backend" in call_arg
# ---------------------------------------------------------------------------
# merge_pr_node
# ---------------------------------------------------------------------------
class TestMergePrNode:
async def test_calls_azdo_merge_pr(self) -> None:
clients = build_mock_clients()
clients.azdo.merge_pr = AsyncMock(return_value=True)
config = build_config(clients)
state = {
"pr_info": {"pr_id": "42"},
"last_merge_source_commit": "abc123",
}
result = await merge_pr_node(state, config)
clients.azdo.merge_pr.assert_called_once_with(
pr_id=42, last_merge_source_commit="abc123"
)
async def test_returns_message_on_success(self) -> None:
clients = build_mock_clients()
clients.azdo.merge_pr = AsyncMock(return_value=True)
config = build_config(clients)
state = {
"pr_info": {"pr_id": "42"},
"last_merge_source_commit": "abc123",
}
result = await merge_pr_node(state, config)
assert "messages" in result
async def test_re_raises_on_service_error(self) -> None:
from release_agent.exceptions import ServiceError
clients = build_mock_clients()
clients.azdo.merge_pr = AsyncMock(side_effect=ServiceError(
service="azdo", status_code=409, detail="Conflict"
))
config = build_config(clients)
state = {
"pr_info": {"pr_id": "42"},
"last_merge_source_commit": "abc123",
}
with pytest.raises(ServiceError):
await merge_pr_node(state, config)
# ---------------------------------------------------------------------------
# move_jira_ready_for_stage
# ---------------------------------------------------------------------------
class TestMoveJiraReadyForStage:
async def test_transitions_ticket(self) -> None:
clients = build_mock_clients()
clients.jira.transition_issue = AsyncMock(return_value=True)
config = build_config(clients)
state = {"ticket_id": "ALLPOST-100", "has_ticket": True}
result = await move_jira_ready_for_stage(state, config)
clients.jira.transition_issue.assert_called_once_with(
"ALLPOST-100", "Ready for stage (2)"
)
async def test_skips_when_no_ticket(self) -> None:
clients = build_mock_clients()
clients.jira.transition_issue = AsyncMock()
config = build_config(clients)
state = {"has_ticket": False}
await move_jira_ready_for_stage(state, config)
clients.jira.transition_issue.assert_not_called()
async def test_appends_error_on_failure(self) -> None:
from release_agent.exceptions import ServiceError
clients = build_mock_clients()
clients.jira.transition_issue = AsyncMock(side_effect=ServiceError(
service="jira", status_code=500, detail="Error"
))
config = build_config(clients)
state = {"ticket_id": "ALLPOST-100", "has_ticket": True}
result = await move_jira_ready_for_stage(state, config)
assert "errors" in result
# ---------------------------------------------------------------------------
# add_jira_pr_link
# ---------------------------------------------------------------------------
class TestAddJiraPrLink:
async def test_calls_add_remote_link(self) -> None:
clients = build_mock_clients()
clients.jira.add_remote_link = AsyncMock(return_value=True)
config = build_config(clients)
state = {
"ticket_id": "ALLPOST-100",
"has_ticket": True,
"pr_info": {
"pr_id": "42",
"pr_url": "https://dev.azure.com/org/proj/_git/repo/pullrequest/42",
"pr_title": "Fix: bug",
},
}
result = await add_jira_pr_link(state, config)
clients.jira.add_remote_link.assert_called_once()
async def test_skips_when_no_ticket(self) -> None:
clients = build_mock_clients()
clients.jira.add_remote_link = AsyncMock()
config = build_config(clients)
state = {"has_ticket": False}
await add_jira_pr_link(state, config)
clients.jira.add_remote_link.assert_not_called()
async def test_appends_error_on_failure(self) -> None:
from release_agent.exceptions import ServiceError
clients = build_mock_clients()
clients.jira.add_remote_link = AsyncMock(side_effect=ServiceError(
service="jira", status_code=500, detail="Error"
))
config = build_config(clients)
state = {
"ticket_id": "ALLPOST-100",
"has_ticket": True,
"pr_info": {
"pr_id": "42",
"pr_url": "https://dev.azure.com/org/proj/_git/repo/pullrequest/42",
"pr_title": "Fix",
},
}
result = await add_jira_pr_link(state, config)
assert "errors" in result
# ---------------------------------------------------------------------------
# calculate_version
# ---------------------------------------------------------------------------
class TestCalculateVersion:
async def test_returns_v1_0_0_for_empty_store(self, tmp_path) -> None:
staging_store = JsonFileStagingStore(directory=tmp_path)
clients = build_mock_clients()
config = build_config(clients, staging_store=staging_store)
state = {"repo_name": "my-repo"}
result = await calculate_version(state, config)
assert result["version"] == "v1.0.0"
async def test_increments_patch_version(self, tmp_path) -> None:
from release_agent.models.release import StagingRelease
staging_store = JsonFileStagingStore(directory=tmp_path)
# Pre-populate with an existing version
staging = StagingRelease(
version="v1.0.5",
repo="my-repo",
started_at=date(2025, 1, 1),
tickets=[],
)
await staging_store.archive(staging, date(2025, 1, 10))
clients = build_mock_clients()
config = build_config(clients, staging_store=staging_store)
state = {"repo_name": "my-repo"}
result = await calculate_version(state, config)
assert result["version"] == "v1.0.6"
async def test_sets_version_in_state(self, tmp_path) -> None:
staging_store = JsonFileStagingStore(directory=tmp_path)
clients = build_mock_clients()
config = build_config(clients, staging_store=staging_store)
state = {"repo_name": "new-repo"}
result = await calculate_version(state, config)
assert "version" in result
assert result["version"].startswith("v")
# ---------------------------------------------------------------------------
# update_staging
# ---------------------------------------------------------------------------
class TestUpdateStaging:
async def test_creates_new_staging_when_none_exists(self, tmp_path) -> None:
staging_store = JsonFileStagingStore(directory=tmp_path)
clients = build_mock_clients()
# Jira get_issue returns a summary
from release_agent.models.jira import JiraIssue
clients.jira.get_issue = AsyncMock(return_value=JiraIssue(
key="ALLPOST-100", summary="Fix auth bug", status="Ready for stage (2)"
))
config = build_config(clients, staging_store=staging_store)
state = {
"repo_name": "my-repo",
"version": "v1.0.0",
"ticket_id": "ALLPOST-100",
"has_ticket": True,
"pr_info": {
"pr_id": "42",
"pr_url": "https://dev.azure.com/org/proj/_git/repo/pullrequest/42",
"pr_title": "Fix: auth bug",
"branch": "feature/ALLPOST-100-fix",
},
}
result = await update_staging(state, config)
loaded = await staging_store.load("my-repo")
assert loaded is not None
assert loaded.has_ticket("ALLPOST-100")
async def test_appends_ticket_to_existing_staging(self, tmp_path) -> None:
from datetime import date
from release_agent.models.release import StagingRelease
from release_agent.models.jira import JiraIssue
staging_store = JsonFileStagingStore(directory=tmp_path)
existing = StagingRelease(
version="v1.0.0", repo="my-repo",
started_at=date(2025, 1, 1), tickets=[]
)
await staging_store.save(existing)
clients = build_mock_clients()
clients.jira.get_issue = AsyncMock(return_value=JiraIssue(
key="BILL-99", summary="New feature", status="Ready"
))
config = build_config(clients, staging_store=staging_store)
state = {
"repo_name": "my-repo",
"version": "v1.0.0",
"ticket_id": "BILL-99",
"has_ticket": True,
"pr_info": {
"pr_id": "55",
"pr_url": "https://dev.azure.com/org/proj/_git/repo/pullrequest/55",
"pr_title": "Feat: new feature",
"branch": "feature/BILL-99-feat",
},
}
await update_staging(state, config)
loaded = await staging_store.load("my-repo")
assert loaded is not None
assert loaded.has_ticket("BILL-99")
async def test_skips_ticket_add_when_no_ticket(self, tmp_path) -> None:
staging_store = JsonFileStagingStore(directory=tmp_path)
clients = build_mock_clients()
config = build_config(clients, staging_store=staging_store)
state = {
"repo_name": "my-repo",
"version": "v1.0.0",
"has_ticket": False,
}
await update_staging(state, config)
# No staging file should be created for ticket-less PR if no existing staging
# (or staging exists without new ticket added)
clients.jira.get_issue.assert_not_called()
async def test_returns_empty_dict_when_no_staging_store(self) -> None:
from release_agent.models.jira import JiraIssue
clients = build_mock_clients()
clients.jira.get_issue = AsyncMock(return_value=JiraIssue(
key="ALLPOST-1", summary="Fix", status="Ready"
))
config = build_config(clients, staging_store=None)
state = {
"repo_name": "my-repo",
"version": "v1.0.0",
"ticket_id": "ALLPOST-1",
"has_ticket": True,
"pr_info": {
"pr_id": "1",
"pr_url": "https://dev.azure.com/org/proj/_git/repo/pullrequest/1",
"pr_title": "Fix",
"branch": "feature/ALLPOST-1",
},
}
result = await update_staging(state, config)
assert result == {}
async def test_uses_ticket_id_as_summary_on_jira_failure(self, tmp_path) -> None:
staging_store = JsonFileStagingStore(directory=tmp_path)
clients = build_mock_clients()
clients.jira.get_issue = AsyncMock(side_effect=Exception("Jira unavailable"))
config = build_config(clients, staging_store=staging_store)
state = {
"repo_name": "my-repo",
"version": "v1.0.0",
"ticket_id": "ALLPOST-99",
"has_ticket": True,
"pr_info": {
"pr_id": "5",
"pr_url": "https://dev.azure.com/org/proj/_git/repo/pullrequest/5",
"pr_title": "Fix something",
"branch": "feature/ALLPOST-99_fix",
},
}
result = await update_staging(state, config)
loaded = await staging_store.load("my-repo")
assert loaded is not None
assert loaded.tickets[0].id == "ALLPOST-99"
assert loaded.tickets[0].summary == "ALLPOST-99"
async def test_sets_staging_dict_in_result(self, tmp_path) -> None:
from release_agent.models.jira import JiraIssue
staging_store = JsonFileStagingStore(directory=tmp_path)
clients = build_mock_clients()
clients.jira.get_issue = AsyncMock(return_value=JiraIssue(
key="ALLPOST-1", summary="S", status="Ready"
))
config = build_config(clients, staging_store=staging_store)
state = {
"repo_name": "my-repo",
"version": "v1.0.0",
"ticket_id": "ALLPOST-1",
"has_ticket": True,
"pr_info": {
"pr_id": "1",
"pr_url": "https://dev.azure.com/org/proj/_git/repo/pullrequest/1",
"pr_title": "Fix",
"branch": "feature/ALLPOST-1",
},
}
result = await update_staging(state, config)
assert "staging" in result
assert isinstance(result["staging"], dict)
# ---------------------------------------------------------------------------
# notify_request_changes
# ---------------------------------------------------------------------------
class TestNotifyRequestChanges:
async def test_calls_slack_send_approval_request(self) -> None:
clients = build_mock_clients()
clients.slack.send_approval_request = AsyncMock(return_value=True)
config = build_config(clients)
state = {
"pr_info": {"pr_id": "42", "pr_title": "Fix: bug", "repo_name": "my-repo"},
"review_result": {
"verdict": "request_changes",
"summary": "Too many issues",
"issues": [{"severity": "blocker", "description": "No tests"}],
},
}
result = await notify_request_changes(state, config)
clients.slack.send_approval_request.assert_called_once()
async def test_appends_error_on_slack_failure(self) -> None:
from release_agent.exceptions import ServiceError
clients = build_mock_clients()
clients.slack.send_approval_request = AsyncMock(side_effect=ServiceError(
service="slack", status_code=500, detail="Webhook error"
))
config = build_config(clients)
state = {
"pr_info": {"pr_id": "42", "pr_title": "Fix", "repo_name": "repo"},
"review_result": {"summary": "Issues found", "issues": []},
}
result = await notify_request_changes(state, config)
assert "errors" in result
# ---------------------------------------------------------------------------
# build_pr_completed_graph
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# auto_create_ticket node
# ---------------------------------------------------------------------------
class TestAutoCreateTicket:
"""Tests for the auto_create_ticket node."""
def _make_config_with_jira_project(
self, jira_project: str = "ALLPOST"
):
clients = build_mock_clients()
clients.jira.create_issue = AsyncMock(return_value="ALLPOST-99")
clients.reviewer.generate_ticket_content = AsyncMock(
return_value=("My summary", "My description")
)
config = build_config(clients)
config["configurable"]["default_jira_project"] = jira_project
return config, clients
async def test_creates_jira_issue_and_returns_ticket_id(self) -> None:
config, clients = self._make_config_with_jira_project()
state = {
"pr_diff": "edit: main.py",
"pr_info": {"pr_title": "Fix bug", "repo_name": "my-repo"},
}
result = await auto_create_ticket(state, config)
assert result.get("ticket_id") == "ALLPOST-99"
async def test_sets_has_ticket_true(self) -> None:
config, clients = self._make_config_with_jira_project()
state = {
"pr_diff": "edit: main.py",
"pr_info": {"pr_title": "Fix bug", "repo_name": "my-repo"},
}
result = await auto_create_ticket(state, config)
assert result.get("has_ticket") is True
async def test_calls_generate_ticket_content(self) -> None:
config, clients = self._make_config_with_jira_project()
state = {
"pr_diff": "edit: main.py",
"pr_info": {"pr_title": "Fix login", "repo_name": "auth-service"},
}
await auto_create_ticket(state, config)
clients.reviewer.generate_ticket_content.assert_awaited_once()
async def test_calls_create_issue_with_project_key(self) -> None:
config, clients = self._make_config_with_jira_project(jira_project="MYPROJ")
clients.jira.create_issue = AsyncMock(return_value="MYPROJ-5")
config["configurable"]["default_jira_project"] = "MYPROJ"
state = {
"pr_diff": "d",
"pr_info": {"pr_title": "t", "repo_name": "r"},
}
await auto_create_ticket(state, config)
call_kwargs = clients.jira.create_issue.call_args.kwargs
assert call_kwargs["project"] == "MYPROJ"
async def test_appends_message_on_success(self) -> None:
config, _ = self._make_config_with_jira_project()
state = {
"pr_diff": "d",
"pr_info": {"pr_title": "t", "repo_name": "r"},
}
result = await auto_create_ticket(state, config)
assert "messages" in result
assert len(result["messages"]) > 0
async def test_appends_error_on_create_issue_failure(self) -> None:
config, clients = self._make_config_with_jira_project()
clients.jira.create_issue = AsyncMock(side_effect=Exception("Jira down"))
state = {
"pr_diff": "d",
"pr_info": {"pr_title": "t", "repo_name": "r"},
}
result = await auto_create_ticket(state, config)
assert "errors" in result
assert len(result["errors"]) > 0
async def test_appends_error_on_generate_content_failure(self) -> None:
config, clients = self._make_config_with_jira_project()
clients.reviewer.generate_ticket_content = AsyncMock(side_effect=RuntimeError("CLI fail"))
state = {
"pr_diff": "d",
"pr_info": {"pr_title": "t", "repo_name": "r"},
}
result = await auto_create_ticket(state, config)
assert "errors" in result
async def test_uses_default_project_from_config(self) -> None:
config, clients = self._make_config_with_jira_project(jira_project="TEAM")
clients.jira.create_issue = AsyncMock(return_value="TEAM-1")
state = {
"pr_diff": "d",
"pr_info": {"pr_title": "t", "repo_name": "r"},
}
result = await auto_create_ticket(state, config)
assert result["ticket_id"] == "TEAM-1"
# ---------------------------------------------------------------------------
# build_pr_completed_graph
# ---------------------------------------------------------------------------
class TestBuildPrCompletedGraph:
def test_returns_compiled_graph(self) -> None:
graph = build_pr_completed_graph()
assert graph is not None
def test_graph_has_nodes(self) -> None:
graph = build_pr_completed_graph()
# The compiled graph object should be truthy
assert graph is not None
def test_graph_includes_trigger_ci_build_node(self) -> None:
graph = build_pr_completed_graph()
# Graph nodes should include CI pipeline nodes
graph_nodes = graph.get_graph().nodes
assert "trigger_ci_build" in graph_nodes
def test_graph_includes_poll_ci_build_node(self) -> None:
graph = build_pr_completed_graph()
graph_nodes = graph.get_graph().nodes
assert "poll_ci_build" in graph_nodes
def test_graph_includes_notify_ci_result_node(self) -> None:
graph = build_pr_completed_graph()
graph_nodes = graph.get_graph().nodes
assert "notify_ci_result" in graph_nodes
def test_graph_includes_auto_create_ticket_node(self) -> None:
graph = build_pr_completed_graph()
graph_nodes = graph.get_graph().nodes
assert "auto_create_ticket" in graph_nodes