diff --git a/CLAUDE.md b/CLAUDE.md index 27b9d1cd..53aea7a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,6 +111,9 @@ podman rm $(podman ps -a --filter name=forge- -q) | `forge:task-pending` | Awaiting task approval | | `forge:blocked` | Workflow blocked, needs intervention | | `forge:retry` | Trigger retry of failed step | +| `forge:yolo` | Autonomous mode — skip all artifact approval gates (see warning below) | + +> **⚠️ Warning — `forge:yolo`:** This label removes all human checkpoints for PRD, spec, plan, and task approval. Forge will proceed autonomously from ticket creation to implementation without pausing for review. Only use this on tickets where you are confident in the requirements and comfortable with Forge making all planning decisions. It does not bypass code review (the human review gate on the implementation PR is always required). ## Jira Comment Syntax diff --git a/README.md b/README.md index bbdca688..99335817 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,16 @@ Use these labels in Jira to control the workflow: | Plan | `forge:plan-pending` | `forge:plan-approved` | | Tasks | `forge:task-pending` | `forge:task-approved` | +### Autonomous Mode (`forge:yolo`) + +> **⚠️ Warning:** Adding `forge:yolo` to a ticket removes all human approval checkpoints for planning artifacts. Forge will proceed from ticket creation straight through to implementation without pausing at the PRD, spec, plan, or task gates. Use this only when you trust the requirements and are comfortable with Forge making all planning decisions autonomously. + +Add `forge:yolo` to a ticket to enable autonomous mode: +- Forge skips the PRD, spec, plan, and task approval gates +- In the bug workflow, Forge auto-selects RCA option 1 +- **The code review gate is never skipped** — a human reviewer is always required on the implementation PR +- `forge:yolo` can be added at ticket creation or while the workflow is already paused at a gate — Forge will immediately advance + ### Jira Comment Syntax Forge classifies Jira comments by their prefix: diff --git a/docs/developer-guide.md b/docs/developer-guide.md index 7a4399dc..c4d8f79a 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -802,6 +802,7 @@ curl -X POST http://localhost:8000/api/v1/webhooks/github \ | `forge:task-approved` | Tasks approved, implementation starts | | `forge:blocked` | Workflow blocked, needs intervention | | `forge:retry` | Resume a blocked workflow | +| `forge:yolo` | Autonomous mode — skip all artifact approval gates (⚠️ use with care, see README) | ### Useful `.env` knobs for development diff --git a/docs/getting-started.md b/docs/getting-started.md index c31d2a62..08694afe 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -125,6 +125,9 @@ For local development you have two options: That's it. Forge will carry the ticket through the full pipeline with similar approval gates at each planning stage. +!!! warning "Skipping approvals with forge:yolo" + Adding `forge:yolo` to a ticket causes Forge to skip all planning approval gates (PRD, spec, plan, tasks) and proceed directly to implementation. Only use this when you trust the requirements fully — there are no checkpoints to catch mistakes before code is written. The code review gate on the implementation PR is always required regardless. + !!! tip "Local development shortcut" Set `FORGE_REQUIRE_PROJECT_CONFIG=false` in `.env` and configure `GITHUB_KNOWN_REPOS` / `GITHUB_DEFAULT_REPO` to skip the Jira project property setup. See the [Developer Guide](developer-guide.md) for details. diff --git a/src/forge/models/workflow.py b/src/forge/models/workflow.py index 786fa252..3904386a 100644 --- a/src/forge/models/workflow.py +++ b/src/forge/models/workflow.py @@ -128,6 +128,7 @@ class ForgeLabel(StrEnum): FORGE_MANAGED = "forge:managed" BLOCKED = "forge:blocked" RETRY = "forge:retry" # Add to trigger retry of current stage + YOLO = "forge:yolo" # Skip human approval gates — auto-approve all artifact reviews class TicketType(StrEnum): diff --git a/src/forge/orchestrator/worker.py b/src/forge/orchestrator/worker.py index 20fa5e71..ebc59490 100644 --- a/src/forge/orchestrator/worker.py +++ b/src/forge/orchestrator/worker.py @@ -21,7 +21,7 @@ from forge.integrations.github.client import GitHubClient from forge.integrations.jira.client import JiraClient from forge.models.events import EventSource -from forge.models.workflow import TicketType +from forge.models.workflow import ForgeLabel, TicketType from forge.orchestrator.checkpointer import get_checkpointer, get_ticket_from_pr_index from forge.queue.consumer import QueueConsumer from forge.queue.models import QueueMessage @@ -43,6 +43,15 @@ def _is_workflow_errored(state: dict) -> bool: # Supports both start-of-line usage (>option 2) and in-prose usage (let's go with >option 2) _OPTION_PATTERN = re.compile(r"(?mi)>option\s+(\d+)") +# Gates where forge:yolo label addition triggers auto-approval and workflow resumption +_YOLO_GATES = { + "prd_approval_gate", + "spec_approval_gate", + "plan_approval_gate", + "task_approval_gate", + "rca_option_gate", +} + class OrchestratorWorker: """Worker that processes workflow events from Redis queue.""" @@ -369,6 +378,7 @@ async def _handle_resume_event( is_retry = False is_question = False is_ci_webhook = False + is_yolo = False pr_merged = False feedback = None @@ -491,6 +501,18 @@ async def _handle_resume_event( to_labels = change.get("toString", "") from_labels = change.get("fromString", "") + # Check for yolo label addition — activate yolo mode if at a gate + if ( + "forge:yolo" in to_labels + and "forge:yolo" not in from_labels + and current_node in _YOLO_GATES + ): + logger.info( + f"forge:yolo label added for {message.ticket_key} at {current_node} " + "— activating yolo mode" + ) + is_yolo = True + # Check for retry label - triggers retry of current stage if "forge:retry" in to_labels.lower() and "forge:retry" not in from_labels.lower(): is_retry = True @@ -832,6 +854,12 @@ async def _handle_resume_event( elif is_ci_webhook: # GitHub CI event — unpause the gate and let ci_evaluator check the results updated_state["is_paused"] = False + elif is_yolo: + updated_state["yolo_mode"] = True + updated_state["is_paused"] = False + updated_state["revision_requested"] = False + updated_state["feedback_comment"] = None + updated_state["last_error"] = None elif is_approved: updated_state["is_paused"] = False updated_state["revision_requested"] = False @@ -1175,13 +1203,15 @@ def _build_initial_state(self, message: QueueMessage) -> dict[str, Any]: Returns: Initial state dictionary. """ - # Extract ticket type from payload + # Extract ticket type and labels from payload ticket_type = "Unknown" # Require explicit type, don't default to Feature + labels: list[str] = [] if message.source == EventSource.JIRA: issue_data = message.payload.get("issue", {}) fields = issue_data.get("fields", {}) issue_type = fields.get("issuetype", {}) ticket_type = issue_type.get("name", "Unknown") + labels = fields.get("labels", []) # Validate ticket type - only Features and Bugs can start workflows directly valid_top_level_types = ("Feature", "Bug", "Story") @@ -1191,6 +1221,8 @@ def _build_initial_state(self, message: QueueMessage) -> dict[str, Any]: f"start a workflow directly. Valid types: {valid_top_level_types}" ) + yolo_mode = ForgeLabel.YOLO in labels + return { "ticket_key": message.ticket_key, "ticket_type": ticket_type, @@ -1203,6 +1235,7 @@ def _build_initial_state(self, message: QueueMessage) -> dict[str, Any]: "current_node": "entry", "is_paused": False, "retry_count": message.retry_count, + "yolo_mode": yolo_mode, } async def start(self) -> None: @@ -1295,6 +1328,7 @@ async def run_single_ticket(ticket_key: str) -> dict[str, Any]: "current_node": "entry", "is_paused": False, "retry_count": 0, + "yolo_mode": False, } # Use ticket_key as thread_id for checkpointing diff --git a/src/forge/workflow/base.py b/src/forge/workflow/base.py index 668915c7..f07f373c 100644 --- a/src/forge/workflow/base.py +++ b/src/forge/workflow/base.py @@ -31,6 +31,7 @@ class BaseState(TypedDict, total=False): # Feedback (human-in-the-loop) feedback_comment: str | None revision_requested: bool + yolo_mode: bool # When True, approval gates auto-pass without human input # Message history messages: Annotated[list[Any], add_messages] diff --git a/src/forge/workflow/bug/state.py b/src/forge/workflow/bug/state.py index aeaf651e..082b1153 100644 --- a/src/forge/workflow/bug/state.py +++ b/src/forge/workflow/bug/state.py @@ -129,6 +129,7 @@ def create_initial_bug_state(ticket_key: str, **kwargs: Any) -> BugState: "qualitative_retry_count": 0, "qualitative_review_failed": False, "reflect_rca_retry_count": 0, + "yolo_mode": False, } # Merge with kwargs, letting kwargs override defaults diff --git a/src/forge/workflow/feature/state.py b/src/forge/workflow/feature/state.py index 5a85a0e7..a8fa504e 100644 --- a/src/forge/workflow/feature/state.py +++ b/src/forge/workflow/feature/state.py @@ -103,6 +103,7 @@ def create_initial_feature_state(ticket_key: str, **kwargs: Any) -> FeatureState "qa_history": [], "generation_context": {}, "is_question": False, + "yolo_mode": False, } # Merge with kwargs, letting kwargs override defaults diff --git a/src/forge/workflow/gates/plan_approval.py b/src/forge/workflow/gates/plan_approval.py index 2a63a414..4e319632 100644 --- a/src/forge/workflow/gates/plan_approval.py +++ b/src/forge/workflow/gates/plan_approval.py @@ -69,6 +69,12 @@ def route_plan_approval(state: WorkflowState) -> str: logger.info(f"Q&A mode: routing to answer_question for {state['ticket_key']}") return "answer_question" + # YOLO mode: auto-approve without human input + if state.get("yolo_mode"): + logger.info(f"YOLO mode: auto-approving plan for {state['ticket_key']}") + record_approval("plan") + return "generate_tasks" + # Check if revision requested if state.get("revision_requested"): feedback = state.get("feedback_comment", "") diff --git a/src/forge/workflow/gates/prd_approval.py b/src/forge/workflow/gates/prd_approval.py index 668f4cd1..10bae1e6 100644 --- a/src/forge/workflow/gates/prd_approval.py +++ b/src/forge/workflow/gates/prd_approval.py @@ -44,6 +44,7 @@ def route_prd_approval(state: WorkflowState) -> str: This routing function determines the next node after PRD approval gate: - If question (Q&A mode) -> answer_question + - If yolo_mode enabled -> auto-approve without human input - If feedback provided (revision requested) -> regenerate PRD - If still paused -> END (wait for next webhook to resume) - Otherwise (approved) -> proceed to spec generation @@ -59,6 +60,12 @@ def route_prd_approval(state: WorkflowState) -> str: logger.info(f"Q&A mode: routing to answer_question for {state['ticket_key']}") return "answer_question" + # YOLO mode: auto-approve without human input + if state.get("yolo_mode"): + logger.info(f"YOLO mode: auto-approving PRD for {state['ticket_key']}") + record_approval("prd") + return "generate_spec" + # Check if revision was requested via comment if state.get("revision_requested") and state.get("feedback_comment"): logger.info(f"PRD revision requested for {state['ticket_key']}") diff --git a/src/forge/workflow/gates/spec_approval.py b/src/forge/workflow/gates/spec_approval.py index 4da037ca..79be4bda 100644 --- a/src/forge/workflow/gates/spec_approval.py +++ b/src/forge/workflow/gates/spec_approval.py @@ -53,6 +53,12 @@ def route_spec_approval(state: WorkflowState) -> str: logger.info(f"Q&A mode: routing to answer_question for {state['ticket_key']}") return "answer_question" + # YOLO mode: auto-approve without human input + if state.get("yolo_mode"): + logger.info(f"YOLO mode: auto-approving spec for {state['ticket_key']}") + record_approval("spec") + return "decompose_epics" + # Check if revision was requested if state.get("revision_requested") and state.get("feedback_comment"): logger.info(f"Spec revision requested for {state['ticket_key']}") diff --git a/src/forge/workflow/gates/task_approval.py b/src/forge/workflow/gates/task_approval.py index 071bc2a1..3717dbe3 100644 --- a/src/forge/workflow/gates/task_approval.py +++ b/src/forge/workflow/gates/task_approval.py @@ -67,6 +67,7 @@ def route_task_approval(state: WorkflowState) -> str: Routing logic: - Question (Q&A mode) -> answer_question + - YOLO mode enabled -> auto-approve without human input - Comment on specific Task ticket -> update_single_task - Comment on Feature ticket -> regenerate_all_tasks - Label changed to approved -> task_router @@ -85,6 +86,12 @@ def route_task_approval(state: WorkflowState) -> str: logger.info(f"Q&A mode: routing to answer_question for {ticket_key}") return "answer_question" + # YOLO mode: auto-approve without human input + if state.get("yolo_mode"): + logger.info(f"YOLO mode: auto-approving tasks for {ticket_key}") + record_approval("task") + return "task_router" + # Check if revision requested (feedback comment added) if state.get("revision_requested"): feedback = state.get("feedback_comment", "") diff --git a/src/forge/workflow/nodes/rca_option_gate.py b/src/forge/workflow/nodes/rca_option_gate.py index bdbe0164..a1e766ac 100644 --- a/src/forge/workflow/nodes/rca_option_gate.py +++ b/src/forge/workflow/nodes/rca_option_gate.py @@ -46,6 +46,20 @@ async def rca_option_gate(state: BugState) -> BugState: finally: await jira.close() + # YOLO mode: auto-select option 1 without pausing + if state.get("yolo_mode") and rca_options: + logger.info(f"YOLO mode: auto-selecting RCA option 1 for {ticket_key}") + return update_state_timestamp( + { + **state, + "rca_comment_posted": True, + "selected_fix_option": 1, + "selected_fix_approach": rca_options[0], + "is_paused": False, + "current_node": "rca_option_gate", + } + ) + paused = set_paused(state, "rca_option_gate") return {**paused, "rca_comment_posted": True} diff --git a/tests/unit/workflow/test_yolo_mode.py b/tests/unit/workflow/test_yolo_mode.py new file mode 100644 index 00000000..b4a261c1 --- /dev/null +++ b/tests/unit/workflow/test_yolo_mode.py @@ -0,0 +1,310 @@ +"""Tests for forge:yolo auto-approval mode.""" + +import pytest + +from forge.models.workflow import ForgeLabel, TicketType +from forge.workflow.feature.state import create_initial_feature_state +from forge.workflow.bug.state import create_initial_bug_state + + +class TestForgeLabelYolo: + def test_yolo_label_value(self): + assert ForgeLabel.YOLO == "forge:yolo" + + def test_yolo_label_is_string(self): + assert isinstance(ForgeLabel.YOLO, str) + + +class TestYoloModeDefaultsToFalse: + def test_feature_state_yolo_mode_defaults_false(self): + state = create_initial_feature_state("TEST-1") + assert state.get("yolo_mode") is False + + def test_bug_state_yolo_mode_defaults_false(self): + state = create_initial_bug_state("BUG-1") + assert state.get("yolo_mode") is False + + def test_feature_state_yolo_mode_can_be_set_true(self): + state = create_initial_feature_state("TEST-1", yolo_mode=True) + assert state["yolo_mode"] is True + + def test_bug_state_yolo_mode_can_be_set_true(self): + state = create_initial_bug_state("BUG-1", yolo_mode=True) + assert state["yolo_mode"] is True + + +class TestBuildInitialStateYoloMode: + """Tests for yolo_mode initialization from Jira payload.""" + + def _make_worker(self): + from unittest.mock import MagicMock + from forge.orchestrator.worker import OrchestratorWorker + worker = OrchestratorWorker.__new__(OrchestratorWorker) + worker.settings = MagicMock() + worker.router = MagicMock() + return worker + + def _make_message(self, labels: list): + from unittest.mock import MagicMock + from forge.models.events import EventSource + msg = MagicMock() + msg.ticket_key = "TEST-1" + msg.source = EventSource.JIRA + msg.event_type = "jira:issue_updated" + msg.event_id = "evt-1" + msg.retry_count = 0 + msg.payload = { + "issue": { + "fields": { + "issuetype": {"name": "Feature"}, + "labels": labels, + } + } + } + return msg + + def test_yolo_mode_true_when_label_present(self): + worker = self._make_worker() + msg = self._make_message(["forge:managed", "forge:yolo"]) + state = worker._build_initial_state(msg) + assert state["yolo_mode"] is True + + def test_yolo_mode_false_when_label_absent(self): + worker = self._make_worker() + msg = self._make_message(["forge:managed"]) + state = worker._build_initial_state(msg) + assert state["yolo_mode"] is False + + def test_yolo_mode_false_when_no_labels(self): + worker = self._make_worker() + msg = self._make_message([]) + state = worker._build_initial_state(msg) + assert state["yolo_mode"] is False + + def test_yolo_mode_false_for_github_source(self): + from unittest.mock import MagicMock + from forge.models.events import EventSource + msg = MagicMock() + msg.ticket_key = "TEST-1" + msg.source = EventSource.GITHUB + msg.event_type = "pull_request" + msg.event_id = "evt-1" + msg.retry_count = 0 + msg.payload = {"pull_request": {"number": 1}} + worker = self._make_worker() + state = worker._build_initial_state(msg) + assert state["yolo_mode"] is False + + +class TestYoloLabelAddedMidWorkflow: + """When forge:yolo is added while paused at a gate, yolo_mode is set and workflow unpauses.""" + + def _make_yolo_label_message(self, current_labels: str, previous_labels: str = "") -> "QueueMessage": + from forge.models.events import EventSource + from forge.queue.models import QueueMessage + return QueueMessage( + message_id="1234567890-0", + event_id="test-event-yolo", + source=EventSource.JIRA, + event_type="jira:issue_updated", + ticket_key="TEST-1", + payload={ + "changelog": { + "items": [ + { + "field": "labels", + "fromString": previous_labels, + "toString": current_labels, + } + ] + }, + "issue": {"fields": {"labels": current_labels.split()}}, + }, + ) + + def _make_gate_state(self, current_node: str, **extra) -> dict: + base = { + "ticket_key": "TEST-1", + "ticket_type": "Feature", + "current_node": current_node, + "is_paused": True, + "yolo_mode": False, + "revision_requested": False, + "feedback_comment": None, + "is_question": False, + "context": {}, + } + return {**base, **extra} + + @pytest.mark.asyncio + async def test_yolo_label_addition_at_prd_gate_activates_yolo(self): + from forge.orchestrator.worker import OrchestratorWorker + worker = OrchestratorWorker(consumer_name="test-worker") + message = self._make_yolo_label_message( + current_labels="forge:managed forge:yolo", + previous_labels="forge:managed", + ) + state = self._make_gate_state("prd_approval_gate") + result = await worker._handle_resume_event(message, state) + assert result["yolo_mode"] is True + assert result["is_paused"] is False + + @pytest.mark.asyncio + async def test_yolo_label_addition_outside_gate_does_not_activate(self): + from forge.orchestrator.worker import OrchestratorWorker + worker = OrchestratorWorker(consumer_name="test-worker") + message = self._make_yolo_label_message( + current_labels="forge:managed forge:yolo", + previous_labels="forge:managed", + ) + state = self._make_gate_state("generate_spec") + result = await worker._handle_resume_event(message, state) + # Not at a gate — is_yolo flag should not fire; workflow must stay paused + assert result.get("yolo_mode") is False + assert result.get("is_paused") is True + + @pytest.mark.asyncio + async def test_yolo_label_already_present_does_not_re_trigger(self): + from forge.orchestrator.worker import OrchestratorWorker + worker = OrchestratorWorker(consumer_name="test-worker") + # forge:yolo was already in fromString — not a new addition + message = self._make_yolo_label_message( + current_labels="forge:yolo forge:prd-approved", + previous_labels="forge:yolo forge:prd-pending", + ) + state = self._make_gate_state("prd_approval_gate", yolo_mode=True) + result = await worker._handle_resume_event(message, state) + # forge:yolo was already present — is_yolo should not re-trigger + # yolo_mode stays True (copied from state), is_paused is False (prd-approved fired) + assert result["yolo_mode"] is True # preserved from input state + + +class TestYoloGateRouting: + """Each approval gate routing function auto-approves when yolo_mode=True.""" + + def _feature_state(self, current_node: str, **extra) -> dict: + from forge.workflow.feature.state import create_initial_feature_state + state = create_initial_feature_state("TEST-1") + state["current_node"] = current_node + state["is_paused"] = True + state["yolo_mode"] = True + state.update(extra) + return state + + def test_prd_route_auto_approves_in_yolo_mode(self): + from forge.workflow.gates.prd_approval import route_prd_approval + state = self._feature_state("prd_approval_gate", prd_content="# PRD") + assert route_prd_approval(state) == "generate_spec" + + def test_spec_route_auto_approves_in_yolo_mode(self): + from forge.workflow.gates.spec_approval import route_spec_approval + state = self._feature_state("spec_approval_gate", spec_content="# Spec") + assert route_spec_approval(state) == "decompose_epics" + + def test_plan_route_auto_approves_in_yolo_mode(self): + from forge.workflow.gates.plan_approval import route_plan_approval + state = self._feature_state("plan_approval_gate", epic_keys=["EPIC-1"]) + assert route_plan_approval(state) == "generate_tasks" + + def test_task_route_auto_approves_in_yolo_mode(self): + from forge.workflow.gates.task_approval import route_task_approval + state = self._feature_state("task_approval_gate", task_keys=["TASK-1"]) + assert route_task_approval(state) == "task_router" + + def test_yolo_false_still_pauses_at_prd_gate(self): + from langgraph.graph import END + from forge.workflow.gates.prd_approval import route_prd_approval + from forge.workflow.feature.state import create_initial_feature_state + state = create_initial_feature_state("TEST-1") + state["current_node"] = "prd_approval_gate" + state["is_paused"] = True + state["yolo_mode"] = False + state["prd_content"] = "# PRD" + assert route_prd_approval(state) == END + + def test_yolo_does_not_override_question_routing(self): + from forge.workflow.gates.prd_approval import route_prd_approval + state = self._feature_state("prd_approval_gate", prd_content="# PRD") + state["is_question"] = True + state["feedback_comment"] = "?Why REST?" + assert route_prd_approval(state) == "answer_question" + + +class TestYoloRcaOptionGate: + """rca_option_gate auto-selects option 1 when yolo_mode=True.""" + + def _rca_state(self, **extra) -> dict: + base = { + "ticket_key": "BUG-1", + "ticket_type": "Bug", + "current_node": "rca_option_gate", + "is_paused": False, + "yolo_mode": True, + "rca_content": "Something broke.", + "rca_comment_posted": False, + "rca_options": [ + {"title": "Fix A", "description": "Patch the null check", "tradeoffs": "Low risk"}, + {"title": "Fix B", "description": "Refactor module", "tradeoffs": "Higher risk"}, + ], + "revision_requested": False, + "feedback_comment": None, + "is_question": False, + "selected_fix_option": None, + "selected_fix_approach": None, + "retry_count": 0, + "last_error": None, + } + return {**base, **extra} + + @pytest.mark.asyncio + async def test_yolo_selects_option_1_without_pausing(self): + from unittest.mock import AsyncMock, patch + from forge.workflow.nodes.rca_option_gate import rca_option_gate + + state = self._rca_state() + mock_jira = AsyncMock() + mock_jira.add_comment = AsyncMock() + mock_jira.set_workflow_label = AsyncMock() + mock_jira.close = AsyncMock() + + with patch("forge.workflow.nodes.rca_option_gate.JiraClient", return_value=mock_jira): + result = await rca_option_gate(state) + + assert result["selected_fix_option"] == 1 + assert result["selected_fix_approach"] == state["rca_options"][0] + assert result["is_paused"] is False + + @pytest.mark.asyncio + async def test_yolo_still_posts_rca_comment(self): + """RCA comment is posted even in yolo mode (audit trail preserved).""" + from unittest.mock import AsyncMock, patch + from forge.workflow.nodes.rca_option_gate import rca_option_gate + + state = self._rca_state() + mock_jira = AsyncMock() + mock_jira.add_comment = AsyncMock() + mock_jira.set_workflow_label = AsyncMock() + mock_jira.close = AsyncMock() + + with patch("forge.workflow.nodes.rca_option_gate.JiraClient", return_value=mock_jira): + await rca_option_gate(state) + + mock_jira.add_comment.assert_called_once() + + @pytest.mark.asyncio + async def test_non_yolo_still_pauses(self): + """With yolo_mode=False, gate pauses normally.""" + from unittest.mock import AsyncMock, patch + from forge.workflow.nodes.rca_option_gate import rca_option_gate + + state = self._rca_state(yolo_mode=False) + mock_jira = AsyncMock() + mock_jira.add_comment = AsyncMock() + mock_jira.set_workflow_label = AsyncMock() + mock_jira.close = AsyncMock() + + with patch("forge.workflow.nodes.rca_option_gate.JiraClient", return_value=mock_jira): + result = await rca_option_gate(state) + + assert result["is_paused"] is True + assert result["selected_fix_option"] is None