diff --git a/src/forge/integrations/agents/agent.py b/src/forge/integrations/agents/agent.py index 30bd18ce..fc1d32d0 100644 --- a/src/forge/integrations/agents/agent.py +++ b/src/forge/integrations/agents/agent.py @@ -1162,7 +1162,9 @@ async def regenerate_document_with_feedback( ) -> ArtifactDocument: """Regenerate a PRD or specification with explicit repository selection.""" if content_type not in {"prd", "spec"}: - raise ValueError(f"Unsupported document type for structured regeneration: {content_type}") + raise ValueError( + f"Unsupported document type for structured regeneration: {content_type}" + ) prompt = load_prompt( "regenerate", content_type=content_type.upper(), @@ -1254,8 +1256,7 @@ async def generate_epics( ) epics = [ - {"summary": epic.summary, "plan": epic.plan, "repo": epic.repo} - for epic in result.epics + {"summary": epic.summary, "plan": epic.plan, "repo": epic.repo} for epic in result.epics ] logger.info(f"Generated {len(epics)} Epics") return epics diff --git a/src/forge/workflow/nodes/task_generation.py b/src/forge/workflow/nodes/task_generation.py index 93f3f376..de6c28cd 100644 --- a/src/forge/workflow/nodes/task_generation.py +++ b/src/forge/workflow/nodes/task_generation.py @@ -215,7 +215,9 @@ async def generate_tasks(state: WorkflowState) -> WorkflowState: ) except Exception as exc: logger.warning( - "Failed to report missing repository on Task %s: %s", task_key, exc + "Failed to report missing repository on Task %s: %s", + task_key, + exc, ) # Assign the model tier for the newly created Task (BR-011). diff --git a/tests/flows/status_transitions/test_prd_rejected.py b/tests/flows/status_transitions/test_prd_rejected.py index 7fdba97f..f38311ad 100644 --- a/tests/flows/status_transitions/test_prd_rejected.py +++ b/tests/flows/status_transitions/test_prd_rejected.py @@ -4,12 +4,26 @@ import pytest +from forge.integrations.agents.structured_outputs import ArtifactDocument from forge.models.workflow import TicketType from forge.workflow.feature.state import create_initial_feature_state as create_initial_state from forge.workflow.gates import route_prd_approval from forge.workflow.nodes import regenerate_prd_with_feedback +@pytest.fixture(autouse=True) +def mock_repo_resolution(monkeypatch): + monkeypatch.setattr( + "forge.workflow.nodes.prd_generation.fetch_and_inject_references", + AsyncMock(side_effect=lambda _state, _jira, content: content), + ) + monkeypatch.setattr( + "forge.workflow.nodes.prd_generation.get_effective_repos", + AsyncMock(return_value=["acme/repo"]), + ) + monkeypatch.setattr("forge.workflow.nodes.prd_generation.reconcile_repo_labels", AsyncMock()) + + class TestPrdRejectedOnce: """Tests for single PRD rejection cycle.""" @@ -59,8 +73,9 @@ async def test_regeneration_incorporates_feedback(self, prd_pending_state): mock_jira.close = AsyncMock() mock_agent = MagicMock() - mock_agent.regenerate_with_feedback = AsyncMock( - return_value="""# Product Requirements Document + mock_agent.regenerate_document_with_feedback = AsyncMock( + return_value=ArtifactDocument( + content="""# Product Requirements Document ## Overview Revised PRD with user personas. @@ -72,7 +87,9 @@ async def test_regeneration_incorporates_feedback(self, prd_pending_state): ## Goals - Enable user login - Secure authentication -""" +""", + repositories=["acme/repo"], + ) ) mock_agent.close = AsyncMock() @@ -83,14 +100,15 @@ async def test_regeneration_incorporates_feedback(self, prd_pending_state): new_callable=AsyncMock, return_value=prd_pending_state["prd_content"], ), - patch("forge.workflow.nodes.prd_generation.ensure_repo_labels", new_callable=AsyncMock), - patch("forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent), + patch( + "forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent + ), ): result = await regenerate_prd_with_feedback(prd_pending_state) # Verify agent was called with feedback - mock_agent.regenerate_with_feedback.assert_called_once() - call_kwargs = mock_agent.regenerate_with_feedback.call_args.kwargs + mock_agent.regenerate_document_with_feedback.assert_called_once() + call_kwargs = mock_agent.regenerate_document_with_feedback.call_args.kwargs assert "user persona" in call_kwargs["feedback"].lower() # Verify new content @@ -109,7 +127,9 @@ async def test_after_regeneration_returns_to_pending(self, prd_pending_state): mock_jira.close = AsyncMock() mock_agent = MagicMock() - mock_agent.regenerate_with_feedback = AsyncMock(return_value="# Revised PRD") + mock_agent.regenerate_document_with_feedback = AsyncMock( + return_value=ArtifactDocument(content="# Revised PRD", repositories=["acme/repo"]) + ) mock_agent.close = AsyncMock() with ( @@ -119,8 +139,9 @@ async def test_after_regeneration_returns_to_pending(self, prd_pending_state): new_callable=AsyncMock, return_value=prd_pending_state["prd_content"], ), - patch("forge.workflow.nodes.prd_generation.ensure_repo_labels", new_callable=AsyncMock), - patch("forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent), + patch( + "forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent + ), ): result = await regenerate_prd_with_feedback(prd_pending_state) @@ -184,11 +205,15 @@ async def test_revision_count_increments(self, prd_state_first_revision): mock_agent = MagicMock() # Simulate error to increment retry count - mock_agent.regenerate_with_feedback = AsyncMock(side_effect=Exception("Simulated error")) + mock_agent.regenerate_document_with_feedback = AsyncMock( + side_effect=Exception("Simulated error") + ) mock_agent.close = AsyncMock() with patch("forge.workflow.nodes.prd_generation.JiraClient", return_value=mock_jira): - with patch("forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent): + with patch( + "forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent + ): result = await regenerate_prd_with_feedback(prd_state_first_revision) # Error case increments retry count @@ -222,17 +247,22 @@ async def test_regeneration_uses_original_prd(self, prd_with_context): mock_jira.update_description = AsyncMock() mock_jira.add_comment = AsyncMock() mock_jira.add_structured_comment = AsyncMock() + mock_jira.get_issue = AsyncMock(return_value=MagicMock(project_key="TEST")) mock_jira.close = AsyncMock() mock_agent = MagicMock() - mock_agent.regenerate_with_feedback = AsyncMock(return_value="# Revised") + mock_agent.regenerate_document_with_feedback = AsyncMock( + return_value=ArtifactDocument(content="# Revised", repositories=["acme/repo"]) + ) mock_agent.close = AsyncMock() with patch("forge.workflow.nodes.prd_generation.JiraClient", return_value=mock_jira): - with patch("forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent): + with patch( + "forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent + ): await regenerate_prd_with_feedback(prd_with_context) - call_kwargs = mock_agent.regenerate_with_feedback.call_args.kwargs + call_kwargs = mock_agent.regenerate_document_with_feedback.call_args.kwargs assert call_kwargs["original_content"] == "# Original PRD" assert call_kwargs["content_type"] == "prd" @@ -243,15 +273,20 @@ async def test_feedback_is_passed_to_agent(self, prd_with_context): mock_jira.update_description = AsyncMock() mock_jira.add_comment = AsyncMock() mock_jira.add_structured_comment = AsyncMock() + mock_jira.get_issue = AsyncMock(return_value=MagicMock(project_key="TEST")) mock_jira.close = AsyncMock() mock_agent = MagicMock() - mock_agent.regenerate_with_feedback = AsyncMock(return_value="# Revised") + mock_agent.regenerate_document_with_feedback = AsyncMock( + return_value=ArtifactDocument(content="# Revised", repositories=["acme/repo"]) + ) mock_agent.close = AsyncMock() with patch("forge.workflow.nodes.prd_generation.JiraClient", return_value=mock_jira): - with patch("forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent): + with patch( + "forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent + ): await regenerate_prd_with_feedback(prd_with_context) - call_kwargs = mock_agent.regenerate_with_feedback.call_args.kwargs + call_kwargs = mock_agent.regenerate_document_with_feedback.call_args.kwargs assert "security" in call_kwargs["feedback"].lower() diff --git a/tests/unit/orchestrator/nodes/test_generate_prd.py b/tests/unit/orchestrator/nodes/test_generate_prd.py index fe576956..5cb89559 100644 --- a/tests/unit/orchestrator/nodes/test_generate_prd.py +++ b/tests/unit/orchestrator/nodes/test_generate_prd.py @@ -4,6 +4,7 @@ import pytest +from forge.integrations.agents.structured_outputs import ArtifactDocument from forge.models.workflow import ForgeLabel, TicketType from forge.workflow.feature.state import create_initial_feature_state as create_initial_state from forge.workflow.nodes import generate_prd, regenerate_prd_with_feedback @@ -21,6 +22,16 @@ def initial_state(self): ticket_type=TicketType.FEATURE, ) + @pytest.fixture(autouse=True) + def mock_repo_resolution(self, monkeypatch): + monkeypatch.setattr( + "forge.workflow.nodes.prd_generation.get_effective_repos", + AsyncMock(return_value=["acme/repo"]), + ) + monkeypatch.setattr( + "forge.workflow.nodes.prd_generation.reconcile_repo_labels", AsyncMock() + ) + @pytest.fixture def mock_jira(self): """Mock Jira client.""" @@ -52,7 +63,9 @@ def mock_agent(self): """Mock ForgeAgent.""" mock = MagicMock() mock.generate_prd = AsyncMock( - return_value="# PRD\n\n## Overview\nGenerated PRD content." + return_value=ArtifactDocument( + content="# PRD\n\n## Overview\nGenerated PRD content.", repositories=["acme/repo"] + ) ) mock.close = AsyncMock() return mock @@ -61,7 +74,9 @@ def mock_agent(self): async def test_generates_prd_from_description(self, initial_state, mock_jira, mock_agent): """PRD is generated from issue description.""" with patch("forge.workflow.nodes.prd_generation.JiraClient", return_value=mock_jira): - with patch("forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent): + with patch( + "forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent + ): result = await generate_prd(initial_state) assert result["prd_content"] != "" @@ -71,7 +86,9 @@ async def test_generates_prd_from_description(self, initial_state, mock_jira, mo async def test_updates_current_node(self, initial_state, mock_jira, mock_agent): """Current node is updated after generation.""" with patch("forge.workflow.nodes.prd_generation.JiraClient", return_value=mock_jira): - with patch("forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent): + with patch( + "forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent + ): result = await generate_prd(initial_state) assert result["current_node"] == "prd_approval_gate" @@ -80,7 +97,9 @@ async def test_updates_current_node(self, initial_state, mock_jira, mock_agent): async def test_sets_prd_pending_label(self, initial_state, mock_jira, mock_agent): """PRD pending label is set on Jira issue.""" with patch("forge.workflow.nodes.prd_generation.JiraClient", return_value=mock_jira): - with patch("forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent): + with patch( + "forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent + ): await generate_prd(initial_state) mock_jira.set_workflow_label.assert_called_once() @@ -93,7 +112,9 @@ async def test_clears_previous_error(self, initial_state, mock_jira, mock_agent) initial_state["last_error"] = "Previous error" with patch("forge.workflow.nodes.prd_generation.JiraClient", return_value=mock_jira): - with patch("forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent): + with patch( + "forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent + ): result = await generate_prd(initial_state) assert result["last_error"] is None @@ -116,7 +137,9 @@ async def test_handles_empty_description(self, initial_state, mock_jira, mock_ag ) with patch("forge.workflow.nodes.prd_generation.JiraClient", return_value=mock_jira): - with patch("forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent): + with patch( + "forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent + ): result = await generate_prd(initial_state) assert result["last_error"] is not None @@ -128,7 +151,9 @@ async def test_handles_agent_error(self, initial_state, mock_jira, mock_agent): mock_agent.generate_prd = AsyncMock(side_effect=Exception("API error")) with patch("forge.workflow.nodes.prd_generation.JiraClient", return_value=mock_jira): - with patch("forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent): + with patch( + "forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent + ): result = await generate_prd(initial_state) assert result["last_error"] is not None @@ -146,8 +171,11 @@ def mock_regeneration_enrichment(self, monkeypatch): AsyncMock(return_value="# Original PRD\n\nOriginal content."), ) monkeypatch.setattr( - "forge.workflow.nodes.prd_generation.ensure_repo_labels", - AsyncMock(), + "forge.workflow.nodes.prd_generation.get_effective_repos", + AsyncMock(return_value=["acme/repo"]), + ) + monkeypatch.setattr( + "forge.workflow.nodes.prd_generation.reconcile_repo_labels", AsyncMock() ) @pytest.fixture @@ -179,8 +207,11 @@ def mock_jira(self): def mock_agent(self): """Mock ForgeAgent.""" mock = MagicMock() - mock.regenerate_with_feedback = AsyncMock( - return_value="# Revised PRD\n\n## User Persona\nDetailed user persona." + mock.regenerate_document_with_feedback = AsyncMock( + return_value=ArtifactDocument( + content="# Revised PRD\n\n## User Persona\nDetailed user persona.", + repositories=["acme/repo"], + ) ) mock.close = AsyncMock() return mock @@ -189,19 +220,25 @@ def mock_agent(self): async def test_regenerates_with_feedback(self, state_with_feedback, mock_jira, mock_agent): """PRD is regenerated incorporating feedback.""" with patch("forge.workflow.nodes.prd_generation.JiraClient", return_value=mock_jira): - with patch("forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent): + with patch( + "forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent + ): await regenerate_prd_with_feedback(state_with_feedback) - mock_agent.regenerate_with_feedback.assert_called_once() - call_args = mock_agent.regenerate_with_feedback.call_args + mock_agent.regenerate_document_with_feedback.assert_called_once() + call_args = mock_agent.regenerate_document_with_feedback.call_args assert "user persona" in call_args.kwargs["feedback"].lower() @pytest.mark.asyncio - async def test_clears_feedback_after_regeneration(self, state_with_feedback, mock_jira, mock_agent): + async def test_clears_feedback_after_regeneration( + self, state_with_feedback, mock_jira, mock_agent + ): """Feedback is cleared after regeneration.""" with ( patch("forge.workflow.nodes.prd_generation.JiraClient", return_value=mock_jira), - patch("forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent), + patch( + "forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent + ), ): result = await regenerate_prd_with_feedback(state_with_feedback) @@ -212,7 +249,9 @@ async def test_clears_feedback_after_regeneration(self, state_with_feedback, moc async def test_returns_to_approval_gate(self, state_with_feedback, mock_jira, mock_agent): """Node returns to PRD approval gate.""" with patch("forge.workflow.nodes.prd_generation.JiraClient", return_value=mock_jira): - with patch("forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent): + with patch( + "forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent + ): result = await regenerate_prd_with_feedback(state_with_feedback) assert result["current_node"] == "prd_approval_gate" @@ -226,7 +265,9 @@ async def test_counts_completed_automated_revision( with ( patch("forge.workflow.nodes.prd_generation.JiraClient", return_value=mock_jira), - patch("forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent), + patch( + "forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent + ), ): result = await regenerate_prd_with_feedback(state_with_feedback) @@ -234,14 +275,20 @@ async def test_counts_completed_automated_revision( assert result["automated_review_revision_pending"] is False @pytest.mark.asyncio - async def test_stores_in_comment_when_configured(self, state_with_feedback, mock_jira, mock_agent): + async def test_stores_in_comment_when_configured( + self, state_with_feedback, mock_jira, mock_agent + ): """Regenerated PRD is stored as structured comment when jira_store_in_comments is true.""" mock_settings = MagicMock() mock_settings.jira_store_in_comments = True with patch("forge.workflow.nodes.prd_generation.JiraClient", return_value=mock_jira): - with patch("forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent): - with patch("forge.workflow.nodes.prd_generation.get_settings", return_value=mock_settings): + with patch( + "forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent + ): + with patch( + "forge.workflow.nodes.prd_generation.get_settings", return_value=mock_settings + ): await regenerate_prd_with_feedback(state_with_feedback) mock_jira.add_structured_comment.assert_called_once_with( @@ -253,14 +300,20 @@ async def test_stores_in_comment_when_configured(self, state_with_feedback, mock mock_jira.update_description.assert_not_called() @pytest.mark.asyncio - async def test_stores_in_description_when_configured(self, state_with_feedback, mock_jira, mock_agent): + async def test_stores_in_description_when_configured( + self, state_with_feedback, mock_jira, mock_agent + ): """Regenerated PRD updates description when jira_store_in_comments is false.""" mock_settings = MagicMock() mock_settings.jira_store_in_comments = False with patch("forge.workflow.nodes.prd_generation.JiraClient", return_value=mock_jira): - with patch("forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent): - with patch("forge.workflow.nodes.prd_generation.get_settings", return_value=mock_settings): + with patch( + "forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent + ): + with patch( + "forge.workflow.nodes.prd_generation.get_settings", return_value=mock_settings + ): await regenerate_prd_with_feedback(state_with_feedback) mock_jira.update_description.assert_called_once_with( @@ -280,8 +333,10 @@ async def test_no_feedback_returns_unchanged(self, mock_jira, mock_agent): # No feedback_comment set with patch("forge.workflow.nodes.prd_generation.JiraClient", return_value=mock_jira): - with patch("forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent): + with patch( + "forge.workflow.stations.artifact_generation.ForgeAgent", return_value=mock_agent + ): await regenerate_prd_with_feedback(state) # Agent should not be called - mock_agent.regenerate_with_feedback.assert_not_called() + mock_agent.regenerate_document_with_feedback.assert_not_called()