diff --git a/containers/entrypoint.py b/containers/entrypoint.py index 29022265..ae737e8e 100644 --- a/containers/entrypoint.py +++ b/containers/entrypoint.py @@ -194,9 +194,18 @@ def configure_git() -> None: def git_commit(workspace: Path, message: str) -> bool: """Stage all changes and create a commit.""" try: + # Keep Forge handoff/history files local even if ignore setup is missing + # or an earlier run accidentally staged them. + subprocess.run( + ["git", "rm", "-r", "--cached", "--ignore-unmatch", ".forge"], + cwd=workspace, + capture_output=True, + text=True, + ) + # Stage all changes result = subprocess.run( - ["git", "add", "-A"], + ["git", "add", "-A", "--", ".", ":!.forge", ":!.forge/**"], cwd=workspace, capture_output=True, text=True, @@ -285,6 +294,35 @@ def build_system_prompt( ) +def resolve_container_trace_fields(trace_state: dict[str, Any]) -> tuple[list[str], dict[str, Any]]: + """Resolve Langfuse tags/metadata from container env without full app settings.""" + try: + from forge.integrations.langfuse.fields import ( + parse_trace_fields, + resolve_field, + ) + except Exception as e: + logger.debug(f"Trace field resolution unavailable: {e}") + return [], {} + + tags: list[str] = [] + for field in parse_trace_fields(os.environ.get("LANGFUSE_TRACE_TAGS", ""), allow_tags=True): + value = resolve_field(field, trace_state) + if value: + tags.append(value) + + metadata: dict[str, Any] = {} + for field in parse_trace_fields( + os.environ.get("LANGFUSE_TRACE_METADATA", ""), + allow_tags=False, + ): + value = resolve_field(field, trace_state) + if value is not None: + metadata[field.value] = value + + return tags, metadata + + async def run_agent_task( workspace: Path, task_key: str, @@ -292,6 +330,7 @@ async def run_agent_task( task_description: str, guardrails: str, previous_task_keys: list[str] | None = None, + trace_context: dict[str, Any] | None = None, ) -> bool: """Run Deep Agents to implement the task. @@ -302,6 +341,7 @@ async def run_agent_task( task_description: Detailed task description. guardrails: Repository guidelines. previous_task_keys: List of previously implemented task keys for handoff context. + trace_context: Workflow fields forwarded to Langfuse only. """ # Support both new (LLM_MODEL) and legacy (CLAUDE_MODEL) env var names model_name = os.environ.get("LLM_MODEL") or os.environ.get("CLAUDE_MODEL", "claude-sonnet-4-5@20250929") @@ -336,6 +376,11 @@ async def run_agent_task( system_prompt = build_system_prompt( workspace, task_key, task_summary, task_description, guardrails, previous_task_keys ) + trace_state = { + **(trace_context or {}), + "system_prompt_length": len(system_prompt), + "llm_model": model_name, + } # Determine model type (Gemini vs Claude) is_gemini = model_name.lower().startswith(("gemini", "models/gemini")) @@ -448,10 +493,13 @@ async def run_agent_task( } if langfuse_enabled: + trace_tags, trace_metadata = resolve_container_trace_fields(trace_state) + tags = ["forge-container", "task-implementation", *trace_tags] + metadata = {"task_summary": task_summary, **trace_metadata} with propagate_attributes( session_id=task_key, - tags=["forge-container", "task-implementation"], - metadata={"task_summary": task_summary}, + tags=tags, + metadata=metadata, ): result = await agent.ainvoke(initial_message, config=config) else: @@ -535,6 +583,7 @@ def main(): # Load task details previous_task_keys: list[str] = [] + trace_context: dict[str, Any] = {} task_key: str = "UNKNOWN" if args.task_file: if not args.task_file.exists(): @@ -546,6 +595,8 @@ def main(): task_summary = task_data.get("summary", "") task_description = task_data.get("description", "") previous_task_keys = task_data.get("previous_task_keys", []) + raw_trace_context = task_data.get("trace_context", {}) + trace_context = raw_trace_context if isinstance(raw_trace_context, dict) else {} elif args.task_summary and args.task_description: task_summary = args.task_summary task_description = args.task_description @@ -583,7 +634,13 @@ def main(): # - Committing changes when ready if not asyncio.run( run_agent_task( - workspace, task_key, task_summary, task_description, guardrails, previous_task_keys + workspace, + task_key, + task_summary, + task_description, + guardrails, + previous_task_keys, + trace_context, ) ): logger.error("Task implementation failed") diff --git a/src/forge/sandbox/runner.py b/src/forge/sandbox/runner.py index 5d81afa1..a422ef91 100644 --- a/src/forge/sandbox/runner.py +++ b/src/forge/sandbox/runner.py @@ -19,6 +19,7 @@ import shutil from dataclasses import dataclass, field from pathlib import Path +from typing import Any from forge.config import Settings, get_settings from forge.prompts import load_prompt @@ -149,6 +150,8 @@ def _build_env_vars( env["LANGFUSE_PUBLIC_KEY"] = self.settings.langfuse_public_key env["LANGFUSE_SECRET_KEY"] = self.settings.langfuse_secret_key.get_secret_value() env["LANGFUSE_HOST"] = self.settings.langfuse_host + env["LANGFUSE_TRACE_TAGS"] = self.settings.langfuse_trace_tags + env["LANGFUSE_TRACE_METADATA"] = self.settings.langfuse_trace_metadata logger.debug("Container Langfuse tracing enabled") # Pass system prompt template (unformatted - entrypoint will interpolate) @@ -373,6 +376,7 @@ async def run( task_key: str | None = None, repo_name: str | None = None, previous_task_keys: list[str] | None = None, + trace_context: dict[str, Any] | None = None, ) -> ContainerResult: """Run a task in a container sandbox. @@ -385,6 +389,7 @@ async def run( task_key: Jira task key being implemented. repo_name: Repository name (e.g., "owner/repo") for container naming. previous_task_keys: List of previously implemented task keys for handoff context. + trace_context: Workflow fields forwarded to Langfuse only. Returns: ContainerResult with execution status and logs. @@ -400,6 +405,7 @@ async def run( "summary": task_summary, "description": task_description, "previous_task_keys": previous_task_keys or [], + "trace_context": trace_context or {}, } task_file.write_text(json.dumps(task_data, indent=2)) diff --git a/src/forge/workflow/nodes/ci_evaluator.py b/src/forge/workflow/nodes/ci_evaluator.py index 3a881426..d94d3161 100644 --- a/src/forge/workflow/nodes/ci_evaluator.py +++ b/src/forge/workflow/nodes/ci_evaluator.py @@ -22,6 +22,7 @@ post_status_comment, remove_implementing_label, set_ci_pending_label, + set_review_pending_label, ) from forge.workspace.git_ops import GitOperations from forge.workspace.manager import Workspace @@ -150,6 +151,11 @@ def _is_skipped(check: dict) -> bool: if all_passed: logger.info(f"All CI checks passed for {ticket_key}") + jira = JiraClient() + try: + await set_review_pending_label(jira, ticket_key) + finally: + await jira.close() return update_state_timestamp( { **state, diff --git a/src/forge/workflow/nodes/human_review.py b/src/forge/workflow/nodes/human_review.py index 39b58c03..296eb9d8 100644 --- a/src/forge/workflow/nodes/human_review.py +++ b/src/forge/workflow/nodes/human_review.py @@ -118,12 +118,16 @@ async def aggregate_epic_status(state: WorkflowState) -> WorkflowState: """ ticket_key = state["ticket_key"] epic_keys = state.get("epic_keys", []) + implemented_tasks = state.get("implemented_tasks", []) logger.info(f"Aggregating Epic status for {ticket_key}") jira = JiraClient() try: + if not epic_keys: + epic_keys = await _derive_epic_keys_from_tasks(jira, implemented_tasks) + all_epics_done = True for epic_key in epic_keys: @@ -142,6 +146,7 @@ async def aggregate_epic_status(state: WorkflowState) -> WorkflowState: return update_state_timestamp( { **state, + "epic_keys": epic_keys, "epics_completed": True, "current_node": "aggregate_feature_status", } @@ -248,3 +253,32 @@ async def _check_epic_completion(jira: JiraClient, epic_key: str) -> bool: logger.error(f"Failed to check Epic completion for {epic_key}: {e}") # On error, don't falsely report completion return False + + +async def _derive_epic_keys_from_tasks( + jira: JiraClient, + task_keys: list[str], +) -> list[str]: + """Derive Epic keys from implemented Task parents when state lost them.""" + epic_keys: list[str] = [] + seen: set[str] = set() + + for task_key in task_keys: + try: + issue = await jira.get_issue(task_key) + except Exception as e: + logger.warning(f"Failed to fetch Task {task_key} while deriving Epics: {e}") + continue + + if not issue.parent_key or issue.parent_key in seen: + continue + + seen.add(issue.parent_key) + epic_keys.append(issue.parent_key) + + if epic_keys: + logger.info(f"Derived Epic keys from implemented Tasks: {epic_keys}") + else: + logger.warning("No Epic keys available or derivable from implemented Tasks") + + return epic_keys diff --git a/src/forge/workflow/nodes/implementation.py b/src/forge/workflow/nodes/implementation.py index 55ae81c5..596e06ab 100644 --- a/src/forge/workflow/nodes/implementation.py +++ b/src/forge/workflow/nodes/implementation.py @@ -92,9 +92,6 @@ async def implement_task(state: WorkflowState) -> WorkflowState: f"Uncommitted changes found after all tasks for {ticket_key} — " "committing as fallback" ) - # Remove the .forge/ entry setup_workspace injected into .gitignore - # so we don't pollute the repo's gitignore with Forge internals. - _clean_forge_gitignore(Path(workspace_path)) git.stage_all() git.commit(f"[{ticket_key}] chore: commit uncommitted changes after implementation") @@ -149,6 +146,11 @@ async def implement_task(state: WorkflowState) -> WorkflowState: task_key=current_task, repo_name=current_repo, previous_task_keys=implemented_tasks, + trace_context=_build_implementation_trace_context( + state, + implementation_node=implementation_node, + current_repo=current_repo, + ), ) if result.success: @@ -201,33 +203,23 @@ def _implementation_node_name(state: WorkflowState) -> str: return "implement_bug_fix" if state.get("ticket_type") == TicketType.BUG else "implement_task" -def _clean_forge_gitignore(workspace_path: Path) -> None: - """Remove the .forge/ entry that setup_workspace injected into .gitignore. - - setup_workspace adds a .forge/ exclusion to prevent accidental commits of - workflow state. Before the fallback commit we strip it out so the target - repo's .gitignore isn't polluted with Forge-internal entries. - """ - gitignore_path = workspace_path / ".gitignore" - if not gitignore_path.exists(): - return - - content = gitignore_path.read_text() - if ".forge" not in content: - return - - cleaned = ( - "\n".join( - line - for line in content.splitlines() - if ".forge" not in line and "Forge workflow state" not in line - ).rstrip("\n") - + "\n" - ) - - if cleaned != content: - gitignore_path.write_text(cleaned) - logger.debug("Removed .forge/ entry from .gitignore before fallback commit") +def _build_implementation_trace_context( + state: WorkflowState, + *, + implementation_node: str, + current_repo: str, +) -> dict[str, object]: + """Build trace-only fields for the container's Langfuse labels/metadata.""" + return { + "ticket_key": state.get("ticket_key"), + "ticket_type": state.get("ticket_type"), + "current_node": implementation_node, + "current_repo": current_repo, + "repo": current_repo, + "current_pr_number": state.get("current_pr_number"), + "pr_number": state.get("current_pr_number"), + "retry_count": state.get("retry_count"), + } def _build_task_description( diff --git a/src/forge/workflow/nodes/workspace_setup.py b/src/forge/workflow/nodes/workspace_setup.py index c78ba419..43a780b0 100644 --- a/src/forge/workflow/nodes/workspace_setup.py +++ b/src/forge/workflow/nodes/workspace_setup.py @@ -212,17 +212,16 @@ async def setup_workspace(state: WorkflowState) -> WorkflowState: forge_dir.mkdir(exist_ok=True) (forge_dir / "history").mkdir(exist_ok=True) - # Ensure .forge/ is in .gitignore to prevent accidental commits - gitignore_path = workspace.path / ".gitignore" - if gitignore_path.exists(): - content = gitignore_path.read_text() - if ".forge" not in content: - if not content.endswith("\n"): - content += "\n" - content += "\n# Forge workflow state (do not commit)\n.forge/\n" - gitignore_path.write_text(content) - else: - gitignore_path.write_text("# Forge workflow state (do not commit)\n.forge/\n") + # Keep Forge handoff files local to this clone without modifying the + # target repository's tracked .gitignore. + exclude_path = workspace.path / ".git" / "info" / "exclude" + exclude_path.parent.mkdir(parents=True, exist_ok=True) + exclude_content = exclude_path.read_text() if exclude_path.exists() else "" + if ".forge/" not in exclude_content: + if exclude_content and not exclude_content.endswith("\n"): + exclude_content += "\n" + exclude_content += "\n# Forge workflow state (do not commit)\n.forge/\n" + exclude_path.write_text(exclude_content) logger.info("Created .forge directory for task handoff") diff --git a/src/forge/workflow/utils/__init__.py b/src/forge/workflow/utils/__init__.py index da6a659c..80a10fb1 100644 --- a/src/forge/workflow/utils/__init__.py +++ b/src/forge/workflow/utils/__init__.py @@ -11,6 +11,7 @@ remove_implementing_label, set_ci_pending_label, set_implementing_label, + set_review_pending_label, transition_tasks_to_in_progress, ) from forge.workflow.utils.qa_summary import post_qa_summary_if_needed @@ -90,6 +91,7 @@ def set_error(state: dict[str, Any], error: str) -> dict[str, Any]: "set_error", "set_implementing_label", "set_paused", + "set_review_pending_label", "transition_tasks_to_in_progress", "update_state_timestamp", ] diff --git a/src/forge/workflow/utils/jira_status.py b/src/forge/workflow/utils/jira_status.py index 4d5e51ae..729c3727 100644 --- a/src/forge/workflow/utils/jira_status.py +++ b/src/forge/workflow/utils/jira_status.py @@ -139,3 +139,29 @@ async def set_ci_pending_label( logger.info(f"Set forge:ci-pending label on {feature_key}") except Exception as e: logger.warning(f"Failed to set ci-pending label on {feature_key}: {e}") + + +async def set_review_pending_label( + jira_client: JiraClient, + feature_key: str, +) -> None: + """Set the forge:review-pending label on a feature issue. + + This function suppresses all exceptions to prevent Jira API failures from + blocking workflow execution. Errors are logged at WARNING level. + + Args: + jira_client: JiraClient instance for API calls. + feature_key: The feature/bug key to label. + + Returns: + None. Exceptions are suppressed and logged. + """ + try: + await jira_client.set_workflow_label( + feature_key, + ForgeLabel.TASK_REVIEW_PENDING, + ) + logger.info(f"Set forge:review-pending label on {feature_key}") + except Exception as e: + logger.warning(f"Failed to set review-pending label on {feature_key}: {e}") diff --git a/src/forge/workspace/git_ops.py b/src/forge/workspace/git_ops.py index f7b3fea2..c78a1a78 100644 --- a/src/forge/workspace/git_ops.py +++ b/src/forge/workspace/git_ops.py @@ -207,8 +207,9 @@ def checkout_branch(self, branch_name: str | None = None, remote: str = "origin" logger.info(f"Checked out branch {branch}") def stage_all(self) -> None: - """Stage all changes.""" - self._run_git("add", "-A") + """Stage all user-facing changes, excluding Forge internal files.""" + self._run_git("rm", "-r", "--cached", "--ignore-unmatch", ".forge", check=False) + self._run_git("add", "-A", "--", ".", ":!.forge", ":!.forge/**") def stage_files(self, *files: str) -> None: """Stage specific files. diff --git a/tests/unit/sandbox/test_container_entrypoint_git_commit.py b/tests/unit/sandbox/test_container_entrypoint_git_commit.py new file mode 100644 index 00000000..d11a7d73 --- /dev/null +++ b/tests/unit/sandbox/test_container_entrypoint_git_commit.py @@ -0,0 +1,43 @@ +"""Tests for the container entrypoint git fallback commit.""" + +import importlib.util +import subprocess +from pathlib import Path + + +def _load_entrypoint_module(): + module_path = Path(__file__).parents[3] / "containers" / "entrypoint.py" + spec = importlib.util.spec_from_file_location("forge_container_entrypoint", module_path) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def _git(repo: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", *args], + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + + +def test_git_commit_excludes_forge_directory(tmp_path): + entrypoint = _load_entrypoint_module() + _git(tmp_path, "init") + _git(tmp_path, "config", "user.name", "Forge Test") + _git(tmp_path, "config", "user.email", "forge-test@example.com") + + (tmp_path / "code.txt").write_text("user-facing change\n") + forge_dir = tmp_path / ".forge" + forge_dir.mkdir() + (forge_dir / "handoff.md").write_text("internal handoff\n") + + assert entrypoint.git_commit(tmp_path, "test commit") is True + + tracked = _git(tmp_path, "ls-files").stdout.splitlines() + assert "code.txt" in tracked + assert ".forge/handoff.md" not in tracked diff --git a/tests/unit/sandbox/test_runner_timeout_cleanup.py b/tests/unit/sandbox/test_runner_timeout_cleanup.py index 5651134f..de07e6b9 100644 --- a/tests/unit/sandbox/test_runner_timeout_cleanup.py +++ b/tests/unit/sandbox/test_runner_timeout_cleanup.py @@ -1,10 +1,11 @@ """Tests for container runner timeout cleanup.""" +import json from unittest.mock import AsyncMock, MagicMock, patch import pytest -from forge.sandbox.runner import ContainerRunner +from forge.sandbox.runner import ContainerConfig, ContainerRunner def _runner_without_init() -> ContainerRunner: @@ -68,3 +69,49 @@ async def fake_wait_for(awaitable, timeout): # noqa: ANN001, ARG001 run_process.kill.assert_called_once() assert run_process.wait.call_count == 2 assert run_process.wait.await_count == 1 + + +@pytest.mark.asyncio +async def test_run_writes_trace_context_to_task_file(tmp_path) -> None: + runner = _runner_without_init() + runner.settings = MagicMock() + runner.settings.container_keep = False + runner._build_container_name = MagicMock(return_value="forge-ticket-abc123") + captured_task_data = {} + + def build_command(_workspace_path, task_file, *_args): # noqa: ANN001 + captured_task_data.update(json.loads(task_file.read_text())) + return ["podman", "run", "fake"] + + runner._build_podman_command = MagicMock(side_effect=build_command) + + process = MagicMock() + process.communicate = AsyncMock(return_value=(b"ok", b"")) + process.returncode = 0 + + trace_context = { + "ticket_key": "FEAT-123", + "ticket_type": "Feature", + "current_node": "implement_task", + "current_repo": "org/repo", + } + + with patch( + "forge.sandbox.runner.asyncio.create_subprocess_exec", + new=AsyncMock(return_value=process), + ): + result = await runner.run( + workspace_path=tmp_path, + task_summary="Do it", + task_description="Details", + config=ContainerConfig(), + ticket_key="FEAT-123", + task_key="TASK-1", + repo_name="org/repo", + previous_task_keys=["TASK-0"], + trace_context=trace_context, + ) + + assert result.success is True + assert captured_task_data["trace_context"]["current_node"] == "implement_task" + assert captured_task_data["trace_context"]["current_repo"] == "org/repo" diff --git a/tests/unit/workflow/nodes/test_ci_attempt_tracking.py b/tests/unit/workflow/nodes/test_ci_attempt_tracking.py index 59950ab6..d8ce68eb 100644 --- a/tests/unit/workflow/nodes/test_ci_attempt_tracking.py +++ b/tests/unit/workflow/nodes/test_ci_attempt_tracking.py @@ -3,6 +3,7 @@ import pytest from unittest.mock import AsyncMock, MagicMock, patch +from forge.models.workflow import ForgeLabel from forge.workflow.nodes.ci_evaluator import evaluate_ci_status from forge.workflow.feature.state import FeatureState @@ -283,14 +284,25 @@ async def test_current_attempt_resets_on_ci_success(self): } ] - with patch("forge.workflow.nodes.ci_evaluator.GitHubClient", return_value=github): - with patch("forge.workflow.nodes.ci_evaluator.get_settings") as mock_settings: - mock_settings.return_value.ignored_ci_checks = ["tide"] - result = await evaluate_ci_status(state) + jira = MagicMock() + jira.set_workflow_label = AsyncMock() + jira.close = AsyncMock() + + with ( + patch("forge.workflow.nodes.ci_evaluator.GitHubClient", return_value=github), + patch("forge.workflow.nodes.ci_evaluator.JiraClient", return_value=jira), + patch("forge.workflow.nodes.ci_evaluator.get_settings") as mock_settings, + ): + mock_settings.return_value.ignored_ci_checks = ["tide"] + result = await evaluate_ci_status(state) assert result["ci_fix_attempt"] == 0 assert result["current_node"] == "human_review_gate" assert result["ci_status"] == "passed" + jira.set_workflow_label.assert_awaited_once_with( + "TEST-123", + ForgeLabel.TASK_REVIEW_PENDING, + ) @pytest.mark.asyncio async def test_current_attempt_resets_on_workflow_completion(self): @@ -417,5 +429,3 @@ async def test_max_attempts_one_allows_single_attempt(self): assert result2["ci_fix_attempt"] == 1 # Unchanged assert result2["current_node"] == "ci_evaluator" assert result2["ci_status"] == "failed" - - diff --git a/tests/unit/workflow/nodes/test_human_review_completion.py b/tests/unit/workflow/nodes/test_human_review_completion.py new file mode 100644 index 00000000..b4cb617f --- /dev/null +++ b/tests/unit/workflow/nodes/test_human_review_completion.py @@ -0,0 +1,45 @@ +"""Tests for post-merge Jira completion aggregation.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from forge.models.workflow import JiraStatus +from forge.workflow.nodes.human_review import aggregate_epic_status + + +@pytest.mark.asyncio +async def test_aggregate_epic_status_derives_missing_epics_from_implemented_tasks(): + """Merged workflows should close Epics even when state lost epic_keys.""" + state = { + "ticket_key": "FEAT-123", + "implemented_tasks": ["TASK-1", "TASK-2"], + "epic_keys": [], + "current_node": "aggregate_epic_status", + "retry_count": 0, + } + + jira = MagicMock() + jira.get_issue = AsyncMock( + side_effect=[ + SimpleNamespace(parent_key="EPIC-1"), + SimpleNamespace(parent_key="EPIC-1"), + ] + ) + jira.get_epic_children = AsyncMock( + return_value=[ + SimpleNamespace(key="TASK-1", status="Closed"), + SimpleNamespace(key="TASK-2", status="Done"), + ] + ) + jira.transition_issue = AsyncMock() + jira.close = AsyncMock() + + with patch("forge.workflow.nodes.human_review.JiraClient", return_value=jira): + result = await aggregate_epic_status(state) + + jira.transition_issue.assert_awaited_once_with("EPIC-1", JiraStatus.CLOSED.value) + assert result["epic_keys"] == ["EPIC-1"] + assert result["epics_completed"] is True + assert result["current_node"] == "aggregate_feature_status" diff --git a/tests/unit/workflow/nodes/test_implementation.py b/tests/unit/workflow/nodes/test_implementation.py index d734fdd7..7c673597 100644 --- a/tests/unit/workflow/nodes/test_implementation.py +++ b/tests/unit/workflow/nodes/test_implementation.py @@ -141,6 +141,47 @@ async def test_comment_failure_does_not_block_implementation(self): assert result["last_error"] is None assert "TASK-456" in result["implemented_tasks"] + @pytest.mark.asyncio + async def test_passes_trace_context_to_container_runner(self): + """Container traces receive workflow fields for configured labels.""" + from forge.workflow.nodes.implementation import implement_task + + mock_jira = _make_mock_jira(summary="Fix null pointer in AuthService") + runner = _make_successful_runner() + + with ( + patch( + "forge.workflow.nodes.implementation.JiraClient", + return_value=mock_jira, + ), + patch( + "forge.workflow.nodes.implementation.ContainerRunner", + return_value=runner, + ), + patch("forge.workflow.nodes.implementation.get_settings"), + ): + await implement_task( + _make_state( + ticket_key="FEAT-99", + ticket_type=TicketType.FEATURE, + current_repo="acme/backend", + current_task_key="TASK-100", + tasks_by_repo={"acme/backend": ["TASK-100"]}, + ) + ) + + trace_context = runner.run.call_args.kwargs["trace_context"] + assert trace_context == { + "ticket_key": "FEAT-99", + "ticket_type": TicketType.FEATURE, + "current_node": "implement_task", + "current_repo": "acme/backend", + "repo": "acme/backend", + "current_pr_number": None, + "pr_number": None, + "retry_count": 0, + } + class TestImplementationNodeRouting: diff --git a/tests/unit/workflow/nodes/test_workspace_setup.py b/tests/unit/workflow/nodes/test_workspace_setup.py index 3c489083..e4817466 100644 --- a/tests/unit/workflow/nodes/test_workspace_setup.py +++ b/tests/unit/workflow/nodes/test_workspace_setup.py @@ -1,6 +1,7 @@ """Integration tests for workspace setup node - Jira status updates.""" from pathlib import Path +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import httpx @@ -104,6 +105,43 @@ async def test_workspace_setup_posts_status_comment(self): # Verify workspace was set up assert result["workspace_path"] == str(Path("/tmp/test-workspace")) + @pytest.mark.asyncio + async def test_workspace_setup_uses_local_git_exclude_for_forge_dir(self, tmp_path): + """Forge internals should be ignored without modifying tracked .gitignore.""" + workspace_path = tmp_path / "repo" + (workspace_path / ".git" / "info").mkdir(parents=True) + (workspace_path / ".gitignore").write_text("*.log\n") + workspace = SimpleNamespace( + path=workspace_path, + branch_name="feature/TEST-123", + ) + manager = MagicMock() + manager.create_workspace.return_value = workspace + mock_jira = create_mock_jira_client() + mock_git = create_mock_git_operations() + mock_guardrails_loader = create_mock_guardrails_loader() + + state = create_initial_feature_state( + ticket_key="TEST-123", + current_repo="owner/my-repo", + task_keys=[], + ) + + with ( + patch("forge.workflow.nodes.workspace_setup.JiraClient", return_value=mock_jira), + patch( + "forge.workflow.nodes.workspace_setup.get_workspace_manager", + return_value=manager, + ), + patch("forge.workflow.nodes.workspace_setup.GitOperations", return_value=mock_git), + patch("forge.workflow.nodes.workspace_setup.GuardrailsLoader", mock_guardrails_loader), + ): + await setup_workspace(state) + + assert (workspace_path / ".forge" / "history").is_dir() + assert (workspace_path / ".gitignore").read_text() == "*.log\n" + assert ".forge/" in (workspace_path / ".git" / "info" / "exclude").read_text() + @pytest.mark.asyncio async def test_workspace_setup_handles_missing_repo_name(self): """Should use placeholder text when current_repo is None.""" diff --git a/tests/unit/workflow/utils/test_jira_status.py b/tests/unit/workflow/utils/test_jira_status.py index 12b7ddde..f7363eaf 100644 --- a/tests/unit/workflow/utils/test_jira_status.py +++ b/tests/unit/workflow/utils/test_jira_status.py @@ -10,6 +10,7 @@ from forge.workflow.utils.jira_status import ( post_status_comment, set_implementing_label, + set_review_pending_label, transition_tasks_to_in_progress, ) @@ -64,6 +65,35 @@ async def test_post_status_comment_timeout(self, caplog) -> None: ) +class TestSetReviewPendingLabel: + """Test cases for the set_review_pending_label function.""" + + @pytest.mark.asyncio + async def test_sets_review_pending_workflow_label(self) -> None: + mock_jira = MagicMock() + mock_jira.set_workflow_label = AsyncMock() + + await set_review_pending_label(mock_jira, "TEST-123") + + mock_jira.set_workflow_label.assert_called_once_with( + "TEST-123", + ForgeLabel.TASK_REVIEW_PENDING, + ) + + @pytest.mark.asyncio + async def test_set_review_pending_suppresses_api_failure(self, caplog) -> None: + mock_jira = MagicMock() + mock_jira.set_workflow_label = AsyncMock(side_effect=httpx.HTTPError("API error")) + + await set_review_pending_label(mock_jira, "TEST-123") + + assert any( + "Failed to set review-pending label on TEST-123" in record.message + and record.levelname == "WARNING" + for record in caplog.records + ) + + class TestTransitionTasksToInProgress: """Test cases for the transition_tasks_to_in_progress function.""" diff --git a/tests/unit/workspace/test_git_ops_redaction.py b/tests/unit/workspace/test_git_ops_redaction.py index 3f07b9cc..640e0705 100644 --- a/tests/unit/workspace/test_git_ops_redaction.py +++ b/tests/unit/workspace/test_git_ops_redaction.py @@ -58,3 +58,27 @@ def test_git_error_constructor_redacts_tokens(): assert "ghp_" not in str(error) assert "https://[REDACTED]@github.com/org/repo.git" in str(error) + + +def test_stage_all_excludes_forge_internal_directory(tmp_path): + git = _git_ops(tmp_path) + + with patch.object(git, "_run_git") as run_git: + git.stage_all() + + assert run_git.call_args_list[0].args == ( + "rm", + "-r", + "--cached", + "--ignore-unmatch", + ".forge", + ) + assert run_git.call_args_list[0].kwargs == {"check": False} + assert run_git.call_args_list[1].args == ( + "add", + "-A", + "--", + ".", + ":!.forge", + ":!.forge/**", + )