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.
957 lines
38 KiB
Python
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
|