diff --git a/plugins/titan-plugin-jira/tests/integration/test_analyze_workflow.py b/plugins/titan-plugin-jira/tests/integration/test_analyze_workflow.py index 09ffce89..46871561 100644 --- a/plugins/titan-plugin-jira/tests/integration/test_analyze_workflow.py +++ b/plugins/titan-plugin-jira/tests/integration/test_analyze_workflow.py @@ -275,13 +275,13 @@ def test_workflow_step_2_select_issue(workflow_context): assert workflow_context.get("selected_issue") is not None -def test_workflow_step_3_get_issue_details(workflow_context, mock_jira_client): - """Test step 3: Fetch full issue details.""" +def test_get_issue_step_standalone(workflow_context, mock_jira_client): + """Test get_issue step as standalone functionality (not part of main workflow).""" from titan_plugin_jira.steps.get_issue_step import get_issue_step - # Setup: Issue key from step 2 + # Setup: Issue key workflow_context.data["jira_issue_key"] = "ECAPP-123" - workflow_context.current_step = 3 + workflow_context.current_step = 1 # Execute step result = execute_step_with_metadata(get_issue_step, workflow_context) @@ -295,12 +295,12 @@ def test_workflow_step_3_get_issue_details(workflow_context, mock_jira_client): mock_jira_client.get_issue.assert_called_once_with(key="ECAPP-123", expand=None) -def test_workflow_step_4_ai_analysis(workflow_context, mock_ai_client): - """Test step 4: AI analyzes the issue.""" +def test_workflow_step_3_ai_analysis(workflow_context, mock_ai_client): + """Test step 3: AI analyzes the selected issue (uses selected_issue, not jira_issue).""" from titan_plugin_jira.steps.ai_analyze_issue_step import ai_analyze_issue_requirements_step - # Setup: Issue from step 3 - workflow_context.data["jira_issue"] = create_mock_ticket( + # Setup: Use selected_issue from step 2 (not jira_issue from API call) + workflow_context.data["selected_issue"] = create_mock_ticket( key="ECAPP-123", summary="Fix login bug", description="Users can't login", @@ -309,7 +309,7 @@ def test_workflow_step_4_ai_analysis(workflow_context, mock_ai_client): issue_type="Bug", assignee="john.doe@example.com" ) - workflow_context.current_step = 4 + workflow_context.current_step = 3 # Execute step with metadata merge result = execute_step_with_metadata(ai_analyze_issue_requirements_step, workflow_context) @@ -331,11 +331,10 @@ def test_workflow_step_4_ai_analysis(workflow_context, mock_ai_client): def test_workflow_full_execution(workflow_context, mock_jira_client, mock_ai_client): - """Test complete workflow execution (all 4 steps).""" + """Test complete workflow execution (3 steps: search → select → AI analyze).""" # Import all steps from titan_plugin_jira.steps.search_saved_query_step import search_saved_query_step from titan_plugin_jira.steps.prompt_select_issue_step import prompt_select_issue_step - from titan_plugin_jira.steps.get_issue_step import get_issue_step from titan_plugin_jira.steps.ai_analyze_issue_step import ai_analyze_issue_requirements_step # Step 1: Search issues @@ -349,19 +348,14 @@ def test_workflow_full_execution(workflow_context, mock_jira_client, mock_ai_cli result2 = execute_step_with_metadata(prompt_select_issue_step, workflow_context) assert isinstance(result2, Success) - # Step 3: Get issue details + # Step 3: AI analysis (uses selected_issue directly, no API call) workflow_context.current_step = 3 - result3 = execute_step_with_metadata(get_issue_step, workflow_context) + result3 = execute_step_with_metadata(ai_analyze_issue_requirements_step, workflow_context) assert isinstance(result3, Success) - # Step 4: AI analysis - workflow_context.current_step = 4 - result4 = execute_step_with_metadata(ai_analyze_issue_requirements_step, workflow_context) - assert isinstance(result4, Success) - # Verify final state assert workflow_context.get("ai_analysis") is not None - assert workflow_context.get("jira_issue").key == "ECAPP-123" + assert workflow_context.get("selected_issue").key == "ECAPP-123" # Verify template header is present assert "# JIRA Issue Analysis" in workflow_context.get("ai_analysis") @@ -662,3 +656,146 @@ def test_agent_feature_flags_all_disabled(mock_ai_client, mock_jira_client): assert len(analysis.suggested_subtasks) == 0 assert analysis.complexity_score is None assert analysis.estimated_effort is None + + +# New tests for workflow YAML structure (TESTING.md Section 7) + +def test_workflow_yaml_structure(): + """Test that workflow YAML has correct structure after removing get_issue step.""" + import yaml + from pathlib import Path + + # Path relative to this test file + workflow_path = Path(__file__).parent.parent.parent / "titan_plugin_jira" / "workflows" / "analyze-jira-issues.yaml" + with open(workflow_path) as f: + workflow = yaml.safe_load(f) + + # Verify basic structure + assert workflow["name"] == "Analyze JIRA Open and Ready to Dev Issues" + assert "steps" in workflow + + # Verify step count (should be 3, not 4 - get_issue was removed) + steps = workflow["steps"] + assert len(steps) == 3, \ + f"Expected 3 steps after removing get_issue, got {len(steps)}" + + # Verify step IDs + step_ids = [s["id"] for s in steps] + assert step_ids == [ + "search_open_issues", + "prompt_select_issue", + "ai_analyze_issue" # No get_issue_details anymore + ], f"Unexpected step IDs: {step_ids}" + + # Verify ai_analyze_issue doesn't require jira_issue + # (it uses selected_issue from prompt_select_issue instead) + ai_step = next(s for s in steps if s["id"] == "ai_analyze_issue") + requires = ai_step.get("requires", []) + assert "jira_issue" not in requires, \ + "ai_analyze_issue should not require jira_issue (uses selected_issue instead)" + + +def test_workflow_data_flow_without_get_issue(): + """Test that data flows correctly without the get_issue step.""" + from titan_plugin_jira.steps.search_saved_query_step import search_saved_query_step + from titan_plugin_jira.steps.prompt_select_issue_step import prompt_select_issue_step + from titan_plugin_jira.steps.ai_analyze_issue_step import ai_analyze_issue_requirements_step + from unittest.mock import Mock + + # Create context + mock_jira = Mock() + mock_ai = Mock() + mock_textual = Mock() + + # Mock Jira search + issues = [ + create_mock_ticket(key="ECAPP-123", summary="Bug 1"), + create_mock_ticket(key="ECAPP-124", summary="Bug 2"), + ] + mock_jira.search_issues.return_value = ClientSuccess(data=issues) + + # Mock AI + mock_ai.is_available.return_value = True + mock_response = Mock() + mock_response.content = "AI analysis result" + mock_ai.generate.return_value = mock_response + + # Mock textual input (user selects first issue) + mock_textual.ask_text.return_value = "1" + mock_textual.begin_step = Mock() + mock_textual.end_step = Mock() + mock_textual.loading = Mock() + mock_textual.loading.return_value.__enter__ = Mock() + mock_textual.loading.return_value.__exit__ = Mock() + mock_textual.text = Mock() + mock_textual.success_text = Mock() + mock_textual.bold_text = Mock() + mock_textual.dim_text = Mock() + mock_textual.mount = Mock() + + ctx = Mock() + ctx.data = {} + ctx.get = lambda key, default=None: ctx.data.get(key, default) + ctx.jira = mock_jira + ctx.ai = mock_ai + ctx.textual = mock_textual + + # Mock plugin_manager (needed for saved queries lookup) + mock_plugin = Mock() + mock_config = Mock() + mock_config.saved_queries = None # No custom queries + mock_plugin._config = mock_config + mock_plugin_manager = Mock() + mock_plugin_manager.get_plugin.return_value = mock_plugin + ctx.plugin_manager = mock_plugin_manager + + # Step 1: Search + ctx.data["query_name"] = "open_issues" + result1 = search_saved_query_step(ctx) + assert isinstance(result1, Success) + ctx.data.update(result1.metadata) + + # Verify jira_issues is available + assert "jira_issues" in ctx.data + assert len(ctx.data["jira_issues"]) == 2 + + # Step 2: Select (no get_issue step needed!) + result2 = prompt_select_issue_step(ctx) + assert isinstance(result2, Success) + ctx.data.update(result2.metadata) + + # Verify selected_issue is available (from prompt_select_issue) + assert "selected_issue" in ctx.data, \ + "selected_issue should be available from prompt_select_issue" + assert ctx.data["selected_issue"].key == "ECAPP-123" + + # Mock get_issue for AI agent (agent needs full issue data) + full_issue = create_mock_ticket(key="ECAPP-123", summary="Bug 1") + mock_jira.get_issue.return_value = ClientSuccess(data=full_issue) + + # Step 3: AI analyze (uses selected_issue to get key, then agent fetches full data) + result3 = ai_analyze_issue_requirements_step(ctx) + assert isinstance(result3, Success) + + # The workflow completes successfully. The get_issue_details STEP was removed + # (which was redundant), but the AI agent still fetches the issue internally + # for full analysis. This is expected - the optimization was removing the + # intermediate step, not the agent's internal fetch. + mock_jira.get_issue.assert_called_once_with("ECAPP-123") + + +def test_workflow_params_structure(): + """Test that workflow params are correctly defined.""" + import yaml + from pathlib import Path + + # Path relative to this test file + workflow_path = Path(__file__).parent.parent.parent / "titan_plugin_jira" / "workflows" / "analyze-jira-issues.yaml" + with open(workflow_path) as f: + workflow = yaml.safe_load(f) + + # Verify params + if "params" in workflow: + params = workflow["params"] + # Check expected params + assert "query_name" in params, "query_name param should be defined" diff --git a/plugins/titan-plugin-jira/tests/unit/test_jira_network.py b/plugins/titan-plugin-jira/tests/unit/test_jira_network.py index 7f8f35e1..12871aef 100644 --- a/plugins/titan-plugin-jira/tests/unit/test_jira_network.py +++ b/plugins/titan-plugin-jira/tests/unit/test_jira_network.py @@ -31,10 +31,26 @@ def test_network_initialization(jira_network): def test_network_builds_correct_auth_header(jira_network): - """Test that authentication header is built correctly""" - # The session should have Bearer token in headers + """Test that authentication header is built correctly with Basic Auth""" + import base64 + + # Verify header exists assert "Authorization" in jira_network.session.headers - assert jira_network.session.headers["Authorization"] == "Bearer test-token-123" + auth_header = jira_network.session.headers["Authorization"] + + # Verify format (should start with "Basic ") + assert auth_header.startswith("Basic "), "Should use Basic Auth (not Bearer)" + + # Decode and verify credentials + encoded_part = auth_header.split()[1] + decoded = base64.b64decode(encoded_part).decode() + + assert decoded == "test@example.com:test-token-123", \ + f"Expected 'email:token' format, got '{decoded}'" + + # Verify it matches expected encoding + expected_credentials = base64.b64encode(b"test@example.com:test-token-123").decode() + assert auth_header == f"Basic {expected_credentials}" @patch('titan_plugin_jira.clients.network.jira_network.requests.Session') @@ -278,3 +294,24 @@ def test_network_custom_timeout(): timeout=60 ) assert network.timeout == 60 + + +@patch('titan_plugin_jira.clients.network.jira_network.requests.Session') +def test_network_uses_api_v3_endpoint(mock_session_class): + """Test that API v3 endpoint is used, not v2 (critical for migration).""" + mock_session = MagicMock() + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"key": "TEST-123", "id": "10123"} + mock_response.content = b'{"key": "TEST-123"}' + mock_session.request.return_value = mock_response + mock_session_class.return_value = mock_session + + network = JiraNetwork("https://test.atlassian.net", "test@example.com", "token") + network.make_request("GET", "issue/TEST-123") + + # Verify URL contains /rest/api/3/ (not /rest/api/2/) + call_args = mock_session.request.call_args + url = call_args[0][1] # Second positional argument is the URL + assert "/rest/api/3/" in url, f"Expected API v3, got: {url}" + assert "/rest/api/2/" not in url, f"Still using API v2: {url}" diff --git a/plugins/titan-plugin-jira/tests/unit/test_jira_plugin.py b/plugins/titan-plugin-jira/tests/unit/test_jira_plugin.py new file mode 100644 index 00000000..ef232190 --- /dev/null +++ b/plugins/titan-plugin-jira/tests/unit/test_jira_plugin.py @@ -0,0 +1,130 @@ +""" +Unit tests for JiraPlugin configuration + +Tests plugin configuration schema and default values. +""" + +import pytest +from titan_plugin_jira.plugin import JiraPlugin +from titan_cli.core.plugins.models import JiraPluginConfig + + +class TestPluginConfigSchema: + """Tests for plugin configuration schema.""" + + def test_config_schema_excludes_technical_fields(self): + """Test that technical fields are excluded from wizard (TESTING.md Section 1.3).""" + plugin = JiraPlugin() + schema = plugin.get_config_schema() + + properties = schema.get("properties", {}) + + # These should NOT be in wizard (excluded for simplicity) + assert "timeout" not in properties, \ + "timeout should be excluded from wizard (has default: 30s)" + assert "enable_cache" not in properties, \ + "enable_cache should be excluded from wizard (has default: True)" + assert "cache_ttl" not in properties, \ + "cache_ttl should be excluded from wizard (has default: 300s)" + + def test_config_schema_includes_required_fields(self): + """Test that required fields are present in schema.""" + plugin = JiraPlugin() + schema = plugin.get_config_schema() + + properties = schema.get("properties", {}) + required = schema.get("required", []) + + # These SHOULD be in wizard + assert "base_url" in properties, "base_url should be in wizard" + assert "email" in properties, "email should be in wizard" + + # api_token is required (even though stored in secrets) + assert "api_token" in required, "api_token should be marked as required" + + def test_config_uses_default_values(self): + """Test that technical fields use sensible defaults.""" + config = JiraPluginConfig( + base_url="https://test.atlassian.net", + email="test@example.com" + ) + + # Verify defaults apply automatically + assert config.timeout == 30, \ + "Default timeout should be 30 seconds" + assert config.enable_cache is True, \ + "Cache should be enabled by default" + assert config.cache_ttl == 300, \ + "Default cache TTL should be 300 seconds (5 minutes)" + + def test_config_allows_overriding_defaults(self): + """Test that defaults can be manually overridden in config.toml.""" + config = JiraPluginConfig( + base_url="https://test.atlassian.net", + email="test@example.com", + timeout=60, # Override default + enable_cache=False, # Override default + cache_ttl=600 # Override default + ) + + # Verify overrides work + assert config.timeout == 60 + assert config.enable_cache is False + assert config.cache_ttl == 600 + + def test_config_optional_fields(self): + """Test that optional fields work correctly.""" + # Without default_project + config1 = JiraPluginConfig( + base_url="https://test.atlassian.net", + email="test@example.com" + ) + assert config1.default_project is None + + # With default_project + config2 = JiraPluginConfig( + base_url="https://test.atlassian.net", + email="test@example.com", + default_project="ECAPP" + ) + assert config2.default_project == "ECAPP" + + +class TestPluginConfigValidation: + """Tests for configuration validation.""" + + def test_config_validates_base_url_format(self): + """Test that base_url validation works.""" + from pydantic import ValidationError + + # Valid URL + config = JiraPluginConfig( + base_url="https://company.atlassian.net", + email="user@example.com" + ) + assert config.base_url == "https://company.atlassian.net" + + # Invalid URL should fail validation + with pytest.raises(ValidationError): + JiraPluginConfig( + base_url="not-a-url", + email="user@example.com" + ) + + def test_config_validates_email_format(self): + """Test that email validation works.""" + from pydantic import ValidationError + + # Valid email + config = JiraPluginConfig( + base_url="https://test.atlassian.net", + email="user@example.com" + ) + assert config.email == "user@example.com" + + # Invalid email should fail validation + with pytest.raises(ValidationError): + JiraPluginConfig( + base_url="https://test.atlassian.net", + email="not-an-email" + ) diff --git a/plugins/titan-plugin-jira/titan_plugin_jira/clients/network/jira_network.py b/plugins/titan-plugin-jira/titan_plugin_jira/clients/network/jira_network.py index 870e408e..d1de5f8e 100644 --- a/plugins/titan-plugin-jira/titan_plugin_jira/clients/network/jira_network.py +++ b/plugins/titan-plugin-jira/titan_plugin_jira/clients/network/jira_network.py @@ -6,6 +6,7 @@ NO model parsing, NO business logic. """ +import base64 import json import time from typing import Dict, List, Union @@ -19,9 +20,9 @@ class JiraNetwork: """ - Jira REST API v2 Network Layer. + Jira REST API v3 Network Layer. - Handles HTTP communication with Jira Server/Cloud. + Handles HTTP communication with Jira Cloud. Returns raw JSON (dicts), does NOT parse to models. """ @@ -58,11 +59,13 @@ def __init__( self.timeout = timeout self._logger = get_logger(__name__) - # Setup session with auth + # Setup session with Basic Auth (email:api_token) + # JIRA Cloud requires Basic Auth for API tokens, not Bearer tokens + credentials = base64.b64encode(f"{self.email}:{self.api_token}".encode()).decode() self.session = requests.Session() self.session.headers.update({ "Accept": "application/json", - "Authorization": f"Bearer {self.api_token}" + "Authorization": f"Basic {credentials}" }) def make_request( @@ -91,8 +94,8 @@ def make_request( >>> print(data["key"]) 'PROJ-123' """ - # Build full URL (Jira Server uses API v2) - url = f"{self.base_url}/rest/api/2/{endpoint.lstrip('/')}" + # Build full URL (Jira Cloud uses API v3) + url = f"{self.base_url}/rest/api/3/{endpoint.lstrip('/')}" # Add Content-Type for POST/PUT/PATCH if method.upper() in ('POST', 'PUT', 'PATCH') and 'json' in kwargs: diff --git a/plugins/titan-plugin-jira/titan_plugin_jira/plugin.py b/plugins/titan-plugin-jira/titan_plugin_jira/plugin.py index 86ee88cf..0c554928 100644 --- a/plugins/titan-plugin-jira/titan_plugin_jira/plugin.py +++ b/plugins/titan-plugin-jira/titan_plugin_jira/plugin.py @@ -214,10 +214,22 @@ def get_config_schema(self) -> dict: """ Return JSON schema for plugin configuration. + Technical fields (timeout, enable_cache, cache_ttl) are excluded from the wizard + since they have sensible defaults and most users don't need to change them. + Returns: JSON schema dict with api_token marked as required (even though it's stored in secrets) """ schema = JiraPluginConfig.model_json_schema() + + # Exclude technical fields from wizard (they have good defaults) + # Users can still manually edit config.toml if needed + technical_fields = ["timeout", "enable_cache", "cache_ttl"] + for field in technical_fields: + schema.get("properties", {}).pop(field, None) + if field in schema.get("required", []): + schema["required"].remove(field) + # Ensure api_token is in required list for interactive configuration # (even though it's Optional in the model since it's stored in secrets) if "api_token" not in schema.get("required", []): diff --git a/plugins/titan-plugin-jira/titan_plugin_jira/workflows/analyze-jira-issues.yaml b/plugins/titan-plugin-jira/titan_plugin_jira/workflows/analyze-jira-issues.yaml index f05176a2..c7a09253 100644 --- a/plugins/titan-plugin-jira/titan_plugin_jira/workflows/analyze-jira-issues.yaml +++ b/plugins/titan-plugin-jira/titan_plugin_jira/workflows/analyze-jira-issues.yaml @@ -1,5 +1,5 @@ name: "Analyze JIRA Open and Ready to Dev Issues" -description: "List all JIRA issues in Open or Ready to Dev status and analyze selected issue with AI" +description: "Search open JIRA issues, select one interactively, and analyze it with AI" params: # Can override which saved query to use @@ -19,16 +19,8 @@ steps: plugin: jira step: prompt_select_issue - - id: get_issue_details - name: "Get Full Issue Details" - plugin: jira - step: get_issue - requires: - - jira_issue_key - - id: ai_analyze_issue name: "AI Analyze Issue" plugin: jira step: ai_analyze_issue_requirements - requires: - - jira_issue + # Uses 'selected_issue' from previous step (no API call needed)