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