draft-board 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/backend/.env.example +9 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_patch.txt +195 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_stat.txt +6 -0
- package/app/backend/CURL_EXAMPLES.md +335 -0
- package/app/backend/ENV_SETUP.md +65 -0
- package/app/backend/alembic/env.py +71 -0
- package/app/backend/alembic/script.py.mako +28 -0
- package/app/backend/alembic/versions/001_initial_schema.py +104 -0
- package/app/backend/alembic/versions/002_add_jobs_table.py +52 -0
- package/app/backend/alembic/versions/003_add_workspace_table.py +48 -0
- package/app/backend/alembic/versions/004_add_evidence_table.py +56 -0
- package/app/backend/alembic/versions/005_add_verification_commands.py +32 -0
- package/app/backend/alembic/versions/006_add_planner_lock_table.py +39 -0
- package/app/backend/alembic/versions/007_add_revision_review_tables.py +126 -0
- package/app/backend/alembic/versions/008_add_revision_idempotency_and_traceability.py +52 -0
- package/app/backend/alembic/versions/009_add_job_health_fields.py +46 -0
- package/app/backend/alembic/versions/010_add_review_comment_line_content.py +36 -0
- package/app/backend/alembic/versions/011_add_analysis_cache.py +47 -0
- package/app/backend/alembic/versions/012_add_boards_table.py +102 -0
- package/app/backend/alembic/versions/013_add_ticket_blocking.py +45 -0
- package/app/backend/alembic/versions/014_add_agent_sessions.py +220 -0
- package/app/backend/alembic/versions/015_add_ticket_sort_order.py +33 -0
- package/app/backend/alembic/versions/03220f0b93ae_add_pr_fields_to_ticket.py +49 -0
- package/app/backend/alembic/versions/0c2d89fff3b1_seed_board_configs_from_yaml.py +206 -0
- package/app/backend/alembic/versions/3348e5cf54c1_add_merge_checklist_table.py +67 -0
- package/app/backend/alembic/versions/357c780ee445_add_goal_status.py +34 -0
- package/app/backend/alembic/versions/553340b7e26c_add_autonomy_fields_to_goal.py +65 -0
- package/app/backend/alembic/versions/774dc335c679_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/7b307e847cbd_merge_heads.py +23 -0
- package/app/backend/alembic/versions/82ecd978cc70_add_missing_indexes.py +48 -0
- package/app/backend/alembic/versions/8ef5054dc280_add_normalized_log_entries.py +173 -0
- package/app/backend/alembic/versions/8f3e2bd8ea3b_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/9d17f0698d3b_add_config_column_to_boards_table.py +30 -0
- package/app/backend/alembic/versions/add_agent_conversation_history.py +72 -0
- package/app/backend/alembic/versions/add_job_variant.py +34 -0
- package/app/backend/alembic/versions/add_performance_indexes.py +95 -0
- package/app/backend/alembic/versions/add_repos_and_board_repos.py +174 -0
- package/app/backend/alembic/versions/add_session_id_to_jobs.py +27 -0
- package/app/backend/alembic/versions/add_sqlite_backend_tables.py +104 -0
- package/app/backend/alembic/versions/b10fb0b62240_add_diff_content_to_revisions.py +34 -0
- package/app/backend/alembic.ini +89 -0
- package/app/backend/app/__init__.py +3 -0
- package/app/backend/app/data_dir.py +85 -0
- package/app/backend/app/database.py +70 -0
- package/app/backend/app/database_sync.py +64 -0
- package/app/backend/app/dependencies/__init__.py +5 -0
- package/app/backend/app/dependencies/auth.py +80 -0
- package/app/backend/app/dependencies.py +43 -0
- package/app/backend/app/exceptions.py +178 -0
- package/app/backend/app/executors/__init__.py +1 -0
- package/app/backend/app/executors/adapters/__init__.py +1 -0
- package/app/backend/app/executors/adapters/aider.py +152 -0
- package/app/backend/app/executors/adapters/amazon_q.py +103 -0
- package/app/backend/app/executors/adapters/amp.py +123 -0
- package/app/backend/app/executors/adapters/claude.py +177 -0
- package/app/backend/app/executors/adapters/cline.py +127 -0
- package/app/backend/app/executors/adapters/codex.py +167 -0
- package/app/backend/app/executors/adapters/copilot.py +202 -0
- package/app/backend/app/executors/adapters/cursor.py +87 -0
- package/app/backend/app/executors/adapters/droid.py +123 -0
- package/app/backend/app/executors/adapters/gemini.py +132 -0
- package/app/backend/app/executors/adapters/goose.py +131 -0
- package/app/backend/app/executors/adapters/opencode.py +123 -0
- package/app/backend/app/executors/adapters/qwen.py +123 -0
- package/app/backend/app/executors/plugins/__init__.py +1 -0
- package/app/backend/app/executors/registry.py +202 -0
- package/app/backend/app/executors/spec.py +226 -0
- package/app/backend/app/main.py +486 -0
- package/app/backend/app/middleware/__init__.py +13 -0
- package/app/backend/app/middleware/idempotency.py +426 -0
- package/app/backend/app/middleware/rate_limit.py +312 -0
- package/app/backend/app/middleware/security_headers.py +43 -0
- package/app/backend/app/middleware/timeout.py +37 -0
- package/app/backend/app/models/__init__.py +56 -0
- package/app/backend/app/models/agent_conversation_history.py +56 -0
- package/app/backend/app/models/agent_session.py +127 -0
- package/app/backend/app/models/analysis_cache.py +49 -0
- package/app/backend/app/models/base.py +9 -0
- package/app/backend/app/models/board.py +79 -0
- package/app/backend/app/models/board_repo.py +68 -0
- package/app/backend/app/models/cost_budget.py +42 -0
- package/app/backend/app/models/enums.py +40 -0
- package/app/backend/app/models/evidence.py +132 -0
- package/app/backend/app/models/goal.py +102 -0
- package/app/backend/app/models/idempotency_entry.py +30 -0
- package/app/backend/app/models/job.py +163 -0
- package/app/backend/app/models/job_queue.py +39 -0
- package/app/backend/app/models/kv_store.py +28 -0
- package/app/backend/app/models/merge_checklist.py +87 -0
- package/app/backend/app/models/normalized_log.py +100 -0
- package/app/backend/app/models/planner_lock.py +43 -0
- package/app/backend/app/models/rate_limit_entry.py +25 -0
- package/app/backend/app/models/repo.py +66 -0
- package/app/backend/app/models/review_comment.py +91 -0
- package/app/backend/app/models/review_summary.py +69 -0
- package/app/backend/app/models/revision.py +130 -0
- package/app/backend/app/models/ticket.py +223 -0
- package/app/backend/app/models/ticket_event.py +83 -0
- package/app/backend/app/models/user.py +47 -0
- package/app/backend/app/models/workspace.py +71 -0
- package/app/backend/app/redis_client.py +119 -0
- package/app/backend/app/routers/__init__.py +29 -0
- package/app/backend/app/routers/agents.py +296 -0
- package/app/backend/app/routers/auth.py +94 -0
- package/app/backend/app/routers/board.py +885 -0
- package/app/backend/app/routers/dashboard.py +351 -0
- package/app/backend/app/routers/debug.py +528 -0
- package/app/backend/app/routers/evidence.py +96 -0
- package/app/backend/app/routers/executors.py +324 -0
- package/app/backend/app/routers/goals.py +574 -0
- package/app/backend/app/routers/jobs.py +448 -0
- package/app/backend/app/routers/maintenance.py +172 -0
- package/app/backend/app/routers/merge.py +360 -0
- package/app/backend/app/routers/planner.py +537 -0
- package/app/backend/app/routers/pull_requests.py +382 -0
- package/app/backend/app/routers/repos.py +263 -0
- package/app/backend/app/routers/revisions.py +939 -0
- package/app/backend/app/routers/settings.py +267 -0
- package/app/backend/app/routers/tickets.py +2003 -0
- package/app/backend/app/routers/webhooks.py +143 -0
- package/app/backend/app/routers/websocket.py +249 -0
- package/app/backend/app/schemas/__init__.py +109 -0
- package/app/backend/app/schemas/board.py +87 -0
- package/app/backend/app/schemas/common.py +33 -0
- package/app/backend/app/schemas/evidence.py +87 -0
- package/app/backend/app/schemas/goal.py +90 -0
- package/app/backend/app/schemas/job.py +97 -0
- package/app/backend/app/schemas/merge.py +139 -0
- package/app/backend/app/schemas/planner.py +500 -0
- package/app/backend/app/schemas/repo.py +187 -0
- package/app/backend/app/schemas/review.py +137 -0
- package/app/backend/app/schemas/revision.py +114 -0
- package/app/backend/app/schemas/ticket.py +238 -0
- package/app/backend/app/schemas/ticket_event.py +72 -0
- package/app/backend/app/schemas/workspace.py +19 -0
- package/app/backend/app/services/__init__.py +31 -0
- package/app/backend/app/services/agent_memory_service.py +223 -0
- package/app/backend/app/services/agent_registry.py +346 -0
- package/app/backend/app/services/agent_session_manager.py +318 -0
- package/app/backend/app/services/agent_session_service.py +219 -0
- package/app/backend/app/services/agent_tools.py +379 -0
- package/app/backend/app/services/auth_service.py +98 -0
- package/app/backend/app/services/autonomy_service.py +380 -0
- package/app/backend/app/services/board_repo_service.py +201 -0
- package/app/backend/app/services/board_service.py +326 -0
- package/app/backend/app/services/cleanup_service.py +1085 -0
- package/app/backend/app/services/config_service.py +908 -0
- package/app/backend/app/services/context_gatherer.py +557 -0
- package/app/backend/app/services/cost_tracking_service.py +293 -0
- package/app/backend/app/services/cursor_log_normalizer.py +536 -0
- package/app/backend/app/services/delivery_pipeline.py +440 -0
- package/app/backend/app/services/executor_service.py +634 -0
- package/app/backend/app/services/git_host/__init__.py +11 -0
- package/app/backend/app/services/git_host/factory.py +87 -0
- package/app/backend/app/services/git_host/github.py +270 -0
- package/app/backend/app/services/git_host/gitlab.py +194 -0
- package/app/backend/app/services/git_host/protocol.py +75 -0
- package/app/backend/app/services/git_merge_simple.py +346 -0
- package/app/backend/app/services/git_ops.py +384 -0
- package/app/backend/app/services/github_service.py +233 -0
- package/app/backend/app/services/goal_service.py +113 -0
- package/app/backend/app/services/job_service.py +423 -0
- package/app/backend/app/services/job_watchdog_service.py +424 -0
- package/app/backend/app/services/langchain_adapter.py +122 -0
- package/app/backend/app/services/llm_provider_clients.py +351 -0
- package/app/backend/app/services/llm_service.py +285 -0
- package/app/backend/app/services/log_normalizer.py +342 -0
- package/app/backend/app/services/log_stream_service.py +276 -0
- package/app/backend/app/services/merge_checklist_service.py +264 -0
- package/app/backend/app/services/merge_service.py +784 -0
- package/app/backend/app/services/orchestrator_log.py +84 -0
- package/app/backend/app/services/planner_service.py +1662 -0
- package/app/backend/app/services/planner_tick_sync.py +1040 -0
- package/app/backend/app/services/queued_message_service.py +156 -0
- package/app/backend/app/services/reliability_wrapper.py +389 -0
- package/app/backend/app/services/repo_discovery_service.py +318 -0
- package/app/backend/app/services/review_service.py +334 -0
- package/app/backend/app/services/revision_service.py +389 -0
- package/app/backend/app/services/safe_autopilot.py +510 -0
- package/app/backend/app/services/sqlite_worker.py +372 -0
- package/app/backend/app/services/task_dispatch.py +135 -0
- package/app/backend/app/services/ticket_generation_service.py +1781 -0
- package/app/backend/app/services/ticket_service.py +486 -0
- package/app/backend/app/services/udar_planner_service.py +1007 -0
- package/app/backend/app/services/webhook_service.py +126 -0
- package/app/backend/app/services/workspace_service.py +465 -0
- package/app/backend/app/services/worktree_file_service.py +92 -0
- package/app/backend/app/services/worktree_validator.py +213 -0
- package/app/backend/app/sqlite_kv.py +278 -0
- package/app/backend/app/state_machine.py +128 -0
- package/app/backend/app/templates/__init__.py +5 -0
- package/app/backend/app/templates/registry.py +243 -0
- package/app/backend/app/utils/__init__.py +5 -0
- package/app/backend/app/utils/artifact_reader.py +87 -0
- package/app/backend/app/utils/circuit_breaker.py +229 -0
- package/app/backend/app/utils/db_retry.py +136 -0
- package/app/backend/app/utils/ignored_fields.py +123 -0
- package/app/backend/app/utils/validators.py +54 -0
- package/app/backend/app/websocket/__init__.py +5 -0
- package/app/backend/app/websocket/manager.py +179 -0
- package/app/backend/app/websocket/state_tracker.py +113 -0
- package/app/backend/app/worker.py +3190 -0
- package/app/backend/calculator_tickets.json +40 -0
- package/app/backend/canary_tests.sh +591 -0
- package/app/backend/celerybeat-schedule +0 -0
- package/app/backend/celerybeat-schedule-shm +0 -0
- package/app/backend/celerybeat-schedule-wal +0 -0
- package/app/backend/logs/.gitkeep +3 -0
- package/app/backend/multiplication_division_implementation_tickets.json +55 -0
- package/app/backend/multiplication_division_tickets.json +42 -0
- package/app/backend/pyproject.toml +45 -0
- package/app/backend/requirements-dev.txt +8 -0
- package/app/backend/requirements.txt +20 -0
- package/app/backend/run.sh +30 -0
- package/app/backend/run_with_logs.sh +10 -0
- package/app/backend/scientific_calculator_tickets.json +40 -0
- package/app/backend/scripts/extract_openapi.py +21 -0
- package/app/backend/scripts/seed_demo.py +187 -0
- package/app/backend/setup_demo_review.py +302 -0
- package/app/backend/test_actual_parse.py +41 -0
- package/app/backend/test_agent_streaming.py +61 -0
- package/app/backend/test_parse.py +51 -0
- package/app/backend/test_streaming.py +51 -0
- package/app/backend/test_subprocess_streaming.py +50 -0
- package/app/backend/tests/__init__.py +1 -0
- package/app/backend/tests/conftest.py +46 -0
- package/app/backend/tests/test_auth.py +341 -0
- package/app/backend/tests/test_autonomy_service.py +391 -0
- package/app/backend/tests/test_cleanup_service_safety.py +417 -0
- package/app/backend/tests/test_middleware.py +279 -0
- package/app/backend/tests/test_planner_providers.py +290 -0
- package/app/backend/tests/test_planner_unblock.py +183 -0
- package/app/backend/tests/test_revision_invariants.py +618 -0
- package/app/backend/tests/test_sqlite_kv.py +290 -0
- package/app/backend/tests/test_sqlite_worker.py +353 -0
- package/app/backend/tests/test_task_dispatch.py +100 -0
- package/app/backend/tests/test_ticket_validation.py +304 -0
- package/app/backend/tests/test_udar_agent.py +693 -0
- package/app/backend/tests/test_webhook_service.py +184 -0
- package/app/backend/tickets_output.json +59 -0
- package/app/backend/user_management_tickets.json +50 -0
- package/app/backend/uvicorn.log +0 -0
- package/app/draft.yaml +313 -0
- package/app/frontend/dist/assets/index-LcjCczu5.js +155 -0
- package/app/frontend/dist/assets/index-_FP_279e.css +1 -0
- package/app/frontend/dist/index.html +14 -0
- package/app/frontend/dist/vite.svg +1 -0
- package/app/frontend/package.json +101 -0
- package/bin/cli.js +527 -0
- package/package.json +37 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Integration tests for LLM provider API calls with mocked responses."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from app.exceptions import ConfigurationError, LLMAPIError
|
|
10
|
+
from app.services.llm_provider_clients import (
|
|
11
|
+
call_anthropic,
|
|
12
|
+
call_llm,
|
|
13
|
+
call_openai,
|
|
14
|
+
call_openrouter,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# Use pytest-anyio for async tests (already installed)
|
|
18
|
+
pytestmark = pytest.mark.anyio
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestOpenRouterProvider:
|
|
22
|
+
"""Test OpenRouter API integration with mocked responses."""
|
|
23
|
+
|
|
24
|
+
async def test_successful_response(self):
|
|
25
|
+
"""Test successful OpenRouter API call."""
|
|
26
|
+
mock_response = {
|
|
27
|
+
"choices": [
|
|
28
|
+
{
|
|
29
|
+
"message": {
|
|
30
|
+
"content": json.dumps(
|
|
31
|
+
{
|
|
32
|
+
"tickets": [
|
|
33
|
+
{
|
|
34
|
+
"title": "Test Ticket",
|
|
35
|
+
"description": "Test Description",
|
|
36
|
+
"verification": ["echo 'test'"],
|
|
37
|
+
"notes": None,
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
with patch("httpx.AsyncClient.post") as mock_post:
|
|
48
|
+
mock_post.return_value = httpx.Response(
|
|
49
|
+
200,
|
|
50
|
+
json=mock_response,
|
|
51
|
+
request=httpx.Request("POST", "https://example.com"),
|
|
52
|
+
)
|
|
53
|
+
result = await call_openrouter("test prompt", "fake-key")
|
|
54
|
+
assert "Test Ticket" in result
|
|
55
|
+
|
|
56
|
+
async def test_authentication_error(self):
|
|
57
|
+
"""Test OpenRouter authentication error."""
|
|
58
|
+
mock_response = {
|
|
59
|
+
"error": {"message": "Invalid API key", "code": "invalid_api_key"}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
with patch("httpx.AsyncClient.post") as mock_post:
|
|
63
|
+
mock_post.return_value = httpx.Response(
|
|
64
|
+
401,
|
|
65
|
+
json=mock_response,
|
|
66
|
+
request=httpx.Request("POST", "https://example.com"),
|
|
67
|
+
)
|
|
68
|
+
with pytest.raises(LLMAPIError) as exc_info:
|
|
69
|
+
await call_openrouter("test prompt", "invalid-key")
|
|
70
|
+
|
|
71
|
+
assert "401" in str(exc_info.value)
|
|
72
|
+
assert "openrouter" in str(exc_info.value).lower()
|
|
73
|
+
|
|
74
|
+
async def test_rate_limit_error(self):
|
|
75
|
+
"""Test OpenRouter rate limit error."""
|
|
76
|
+
mock_response = {"error": {"message": "Rate limit exceeded"}}
|
|
77
|
+
|
|
78
|
+
with patch("httpx.AsyncClient.post") as mock_post:
|
|
79
|
+
mock_post.return_value = httpx.Response(
|
|
80
|
+
429,
|
|
81
|
+
json=mock_response,
|
|
82
|
+
request=httpx.Request("POST", "https://example.com"),
|
|
83
|
+
)
|
|
84
|
+
with pytest.raises(LLMAPIError) as exc_info:
|
|
85
|
+
await call_openrouter("test prompt", "fake-key")
|
|
86
|
+
|
|
87
|
+
assert (
|
|
88
|
+
"429" in str(exc_info.value)
|
|
89
|
+
or "rate limit" in str(exc_info.value).lower()
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
async def test_timeout_handled(self):
|
|
93
|
+
"""Test that timeouts are handled gracefully."""
|
|
94
|
+
with patch("httpx.AsyncClient.post") as mock_post:
|
|
95
|
+
mock_post.side_effect = httpx.TimeoutException("Request timed out")
|
|
96
|
+
|
|
97
|
+
with pytest.raises(LLMAPIError) as exc_info:
|
|
98
|
+
await call_openrouter("test prompt", "fake-key")
|
|
99
|
+
|
|
100
|
+
assert "timed out" in str(exc_info.value).lower()
|
|
101
|
+
|
|
102
|
+
async def test_network_error_handled(self):
|
|
103
|
+
"""Test that network errors are handled gracefully."""
|
|
104
|
+
with patch("httpx.AsyncClient.post") as mock_post:
|
|
105
|
+
mock_post.side_effect = httpx.ConnectError("Connection failed")
|
|
106
|
+
|
|
107
|
+
with pytest.raises(LLMAPIError) as exc_info:
|
|
108
|
+
await call_openrouter("test prompt", "fake-key")
|
|
109
|
+
|
|
110
|
+
assert (
|
|
111
|
+
"connection" in str(exc_info.value).lower()
|
|
112
|
+
or "network" in str(exc_info.value).lower()
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
async def test_invalid_json_response_returns_raw_text(self):
|
|
116
|
+
"""Test that invalid JSON in response body still returns text content."""
|
|
117
|
+
invalid_response = {
|
|
118
|
+
"choices": [
|
|
119
|
+
{"message": {"content": "Here's some text before {invalid json"}}
|
|
120
|
+
]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
with patch("httpx.AsyncClient.post") as mock_post:
|
|
124
|
+
mock_post.return_value = httpx.Response(
|
|
125
|
+
200,
|
|
126
|
+
json=invalid_response,
|
|
127
|
+
request=httpx.Request("POST", "https://example.com"),
|
|
128
|
+
)
|
|
129
|
+
# call_openrouter extracts text content as-is (no JSON validation)
|
|
130
|
+
result = await call_openrouter("test prompt", "fake-key")
|
|
131
|
+
assert "invalid json" in result
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class TestAnthropicProvider:
|
|
135
|
+
"""Test Anthropic API integration with mocked responses."""
|
|
136
|
+
|
|
137
|
+
async def test_successful_response(self):
|
|
138
|
+
"""Test successful Anthropic API call."""
|
|
139
|
+
mock_response = {
|
|
140
|
+
"content": [
|
|
141
|
+
{
|
|
142
|
+
"text": json.dumps(
|
|
143
|
+
{
|
|
144
|
+
"tickets": [
|
|
145
|
+
{
|
|
146
|
+
"title": "Anthropic Ticket",
|
|
147
|
+
"description": "Test Description",
|
|
148
|
+
"verification": ["echo 'test'"],
|
|
149
|
+
}
|
|
150
|
+
]
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
]
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
with patch("httpx.AsyncClient.post") as mock_post:
|
|
158
|
+
mock_post.return_value = httpx.Response(
|
|
159
|
+
200,
|
|
160
|
+
json=mock_response,
|
|
161
|
+
request=httpx.Request("POST", "https://example.com"),
|
|
162
|
+
)
|
|
163
|
+
result = await call_anthropic("test prompt", "fake-key")
|
|
164
|
+
assert "Anthropic Ticket" in result
|
|
165
|
+
|
|
166
|
+
async def test_authentication_error(self):
|
|
167
|
+
"""Test Anthropic authentication error."""
|
|
168
|
+
mock_response = {
|
|
169
|
+
"error": {"type": "authentication_error", "message": "Invalid API key"}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
with patch("httpx.AsyncClient.post") as mock_post:
|
|
173
|
+
mock_post.return_value = httpx.Response(
|
|
174
|
+
401,
|
|
175
|
+
json=mock_response,
|
|
176
|
+
request=httpx.Request("POST", "https://example.com"),
|
|
177
|
+
)
|
|
178
|
+
with pytest.raises(LLMAPIError) as exc_info:
|
|
179
|
+
await call_anthropic("test prompt", "invalid-key")
|
|
180
|
+
|
|
181
|
+
assert "401" in str(exc_info.value)
|
|
182
|
+
|
|
183
|
+
async def test_empty_content_array(self):
|
|
184
|
+
"""Test Anthropic response with empty content array."""
|
|
185
|
+
mock_response = {"content": []}
|
|
186
|
+
|
|
187
|
+
with patch("httpx.AsyncClient.post") as mock_post:
|
|
188
|
+
mock_post.return_value = httpx.Response(
|
|
189
|
+
200,
|
|
190
|
+
json=mock_response,
|
|
191
|
+
request=httpx.Request("POST", "https://example.com"),
|
|
192
|
+
)
|
|
193
|
+
with pytest.raises(LLMAPIError) as exc_info:
|
|
194
|
+
await call_anthropic("test prompt", "fake-key")
|
|
195
|
+
|
|
196
|
+
assert (
|
|
197
|
+
"empty" in str(exc_info.value).lower()
|
|
198
|
+
or "content" in str(exc_info.value).lower()
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class TestOpenAIProvider:
|
|
203
|
+
"""Test OpenAI API integration with mocked responses."""
|
|
204
|
+
|
|
205
|
+
async def test_successful_response(self):
|
|
206
|
+
"""Test successful OpenAI API call."""
|
|
207
|
+
mock_response = {
|
|
208
|
+
"choices": [
|
|
209
|
+
{
|
|
210
|
+
"message": {
|
|
211
|
+
"content": json.dumps(
|
|
212
|
+
{
|
|
213
|
+
"tickets": [
|
|
214
|
+
{
|
|
215
|
+
"title": "OpenAI Ticket",
|
|
216
|
+
"description": "Test Description",
|
|
217
|
+
"verification": ["echo 'test'"],
|
|
218
|
+
}
|
|
219
|
+
]
|
|
220
|
+
}
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
]
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
with patch("httpx.AsyncClient.post") as mock_post:
|
|
228
|
+
mock_post.return_value = httpx.Response(
|
|
229
|
+
200,
|
|
230
|
+
json=mock_response,
|
|
231
|
+
request=httpx.Request("POST", "https://example.com"),
|
|
232
|
+
)
|
|
233
|
+
result = await call_openai("test prompt", "fake-key")
|
|
234
|
+
assert "OpenAI Ticket" in result
|
|
235
|
+
|
|
236
|
+
async def test_authentication_error(self):
|
|
237
|
+
"""Test OpenAI authentication error."""
|
|
238
|
+
mock_response = {
|
|
239
|
+
"error": {
|
|
240
|
+
"message": "Incorrect API key provided",
|
|
241
|
+
"type": "invalid_request_error",
|
|
242
|
+
"code": "invalid_api_key",
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
with patch("httpx.AsyncClient.post") as mock_post:
|
|
247
|
+
mock_post.return_value = httpx.Response(
|
|
248
|
+
401,
|
|
249
|
+
json=mock_response,
|
|
250
|
+
request=httpx.Request("POST", "https://example.com"),
|
|
251
|
+
)
|
|
252
|
+
with pytest.raises(LLMAPIError) as exc_info:
|
|
253
|
+
await call_openai("test prompt", "invalid-key")
|
|
254
|
+
|
|
255
|
+
assert "401" in str(exc_info.value)
|
|
256
|
+
|
|
257
|
+
async def test_timeout_handled(self):
|
|
258
|
+
"""Test that OpenAI timeouts are handled gracefully."""
|
|
259
|
+
with patch("httpx.AsyncClient.post") as mock_post:
|
|
260
|
+
mock_post.side_effect = httpx.TimeoutException("Request timed out")
|
|
261
|
+
|
|
262
|
+
with pytest.raises(LLMAPIError) as exc_info:
|
|
263
|
+
await call_openai("test prompt", "fake-key")
|
|
264
|
+
|
|
265
|
+
assert "timed out" in str(exc_info.value).lower()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class TestProviderConfiguration:
|
|
269
|
+
"""Test provider configuration and error handling."""
|
|
270
|
+
|
|
271
|
+
async def test_missing_api_key_raises_configuration_error(self):
|
|
272
|
+
"""Test that missing API key raises ConfigurationError."""
|
|
273
|
+
with patch.dict("os.environ", {}, clear=True):
|
|
274
|
+
with pytest.raises(ConfigurationError) as exc_info:
|
|
275
|
+
await call_llm("test prompt", provider="openrouter")
|
|
276
|
+
|
|
277
|
+
assert "api key" in str(exc_info.value).lower()
|
|
278
|
+
assert "OPENROUTER_API_KEY" in str(exc_info.value)
|
|
279
|
+
|
|
280
|
+
async def test_unknown_provider_raises_configuration_error(self):
|
|
281
|
+
"""Test that unknown provider raises ConfigurationError.
|
|
282
|
+
|
|
283
|
+
Note: call_llm checks for missing API key before checking provider,
|
|
284
|
+
so an unknown provider with no matching API key raises a key error.
|
|
285
|
+
"""
|
|
286
|
+
with patch.dict("os.environ", {}, clear=True):
|
|
287
|
+
with pytest.raises(ConfigurationError) as exc_info:
|
|
288
|
+
await call_llm("test prompt", provider="fake_provider")
|
|
289
|
+
|
|
290
|
+
assert "api key" in str(exc_info.value).lower()
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Tests for the sync planner unblock logic (_unblock_ready_tickets_sync)."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import create_engine
|
|
6
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
7
|
+
from sqlalchemy.pool import StaticPool
|
|
8
|
+
|
|
9
|
+
from app.models.base import Base
|
|
10
|
+
from app.models.board import Board
|
|
11
|
+
from app.models.goal import Goal
|
|
12
|
+
from app.models.ticket import Ticket
|
|
13
|
+
from app.models.ticket_event import TicketEvent
|
|
14
|
+
from app.state_machine import TicketState
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _make_sync_session() -> Session:
|
|
18
|
+
"""Create an in-memory sync session with all tables."""
|
|
19
|
+
engine = create_engine(
|
|
20
|
+
"sqlite:///:memory:",
|
|
21
|
+
connect_args={"check_same_thread": False},
|
|
22
|
+
poolclass=StaticPool,
|
|
23
|
+
)
|
|
24
|
+
Base.metadata.create_all(engine)
|
|
25
|
+
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
|
26
|
+
return factory()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _seed(db: Session):
|
|
30
|
+
"""Seed a board, goal, a blocker ticket, and a blocked ticket."""
|
|
31
|
+
board = Board(id=str(uuid.uuid4()), name="test", repo_root="/tmp/repo")
|
|
32
|
+
db.add(board)
|
|
33
|
+
db.flush()
|
|
34
|
+
|
|
35
|
+
goal = Goal(id=str(uuid.uuid4()), board_id=board.id, title="Test goal")
|
|
36
|
+
db.add(goal)
|
|
37
|
+
db.flush()
|
|
38
|
+
|
|
39
|
+
blocker = Ticket(
|
|
40
|
+
id=str(uuid.uuid4()),
|
|
41
|
+
board_id=board.id,
|
|
42
|
+
goal_id=goal.id,
|
|
43
|
+
title="Blocker ticket",
|
|
44
|
+
state=TicketState.DONE.value,
|
|
45
|
+
)
|
|
46
|
+
db.add(blocker)
|
|
47
|
+
db.flush()
|
|
48
|
+
|
|
49
|
+
blocked = Ticket(
|
|
50
|
+
id=str(uuid.uuid4()),
|
|
51
|
+
board_id=board.id,
|
|
52
|
+
goal_id=goal.id,
|
|
53
|
+
title="Blocked ticket",
|
|
54
|
+
state=TicketState.BLOCKED.value,
|
|
55
|
+
blocked_by_ticket_id=blocker.id,
|
|
56
|
+
)
|
|
57
|
+
db.add(blocked)
|
|
58
|
+
db.flush()
|
|
59
|
+
|
|
60
|
+
return board, goal, blocker, blocked
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_unblock_when_blocker_is_done():
|
|
64
|
+
"""Blocked ticket should transition to PLANNED when its blocker is DONE."""
|
|
65
|
+
from app.services.planner_tick_sync import _unblock_ready_tickets_sync
|
|
66
|
+
|
|
67
|
+
db = _make_sync_session()
|
|
68
|
+
_board, _goal, _blocker, blocked = _seed(db)
|
|
69
|
+
db.commit()
|
|
70
|
+
|
|
71
|
+
count = _unblock_ready_tickets_sync(db)
|
|
72
|
+
|
|
73
|
+
assert count == 1
|
|
74
|
+
db.refresh(blocked)
|
|
75
|
+
assert blocked.state == TicketState.PLANNED.value
|
|
76
|
+
|
|
77
|
+
# Verify a TRANSITIONED event was created
|
|
78
|
+
events = db.query(TicketEvent).filter_by(ticket_id=blocked.id).all()
|
|
79
|
+
assert len(events) == 1
|
|
80
|
+
assert events[0].from_state == TicketState.BLOCKED.value
|
|
81
|
+
assert events[0].to_state == TicketState.PLANNED.value
|
|
82
|
+
assert "Unblocked" in events[0].reason
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_no_unblock_when_blocker_not_done():
|
|
86
|
+
"""Blocked ticket should stay BLOCKED when its blocker is still executing."""
|
|
87
|
+
from app.services.planner_tick_sync import _unblock_ready_tickets_sync
|
|
88
|
+
|
|
89
|
+
db = _make_sync_session()
|
|
90
|
+
_board, _goal, blocker, blocked = _seed(db)
|
|
91
|
+
|
|
92
|
+
# Override blocker to EXECUTING (not done yet)
|
|
93
|
+
blocker.state = TicketState.EXECUTING.value
|
|
94
|
+
db.commit()
|
|
95
|
+
|
|
96
|
+
count = _unblock_ready_tickets_sync(db)
|
|
97
|
+
|
|
98
|
+
assert count == 0
|
|
99
|
+
db.refresh(blocked)
|
|
100
|
+
assert blocked.state == TicketState.BLOCKED.value
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_no_unblock_for_blocked_without_dependency():
|
|
104
|
+
"""BLOCKED ticket without blocked_by_ticket_id should not be touched."""
|
|
105
|
+
from app.services.planner_tick_sync import _unblock_ready_tickets_sync
|
|
106
|
+
|
|
107
|
+
db = _make_sync_session()
|
|
108
|
+
board = Board(id=str(uuid.uuid4()), name="test", repo_root="/tmp/repo")
|
|
109
|
+
db.add(board)
|
|
110
|
+
db.flush()
|
|
111
|
+
|
|
112
|
+
goal = Goal(id=str(uuid.uuid4()), board_id=board.id, title="Test goal")
|
|
113
|
+
db.add(goal)
|
|
114
|
+
db.flush()
|
|
115
|
+
|
|
116
|
+
# Blocked by failure, NOT by another ticket
|
|
117
|
+
ticket = Ticket(
|
|
118
|
+
id=str(uuid.uuid4()),
|
|
119
|
+
board_id=board.id,
|
|
120
|
+
goal_id=goal.id,
|
|
121
|
+
title="Failed ticket",
|
|
122
|
+
state=TicketState.BLOCKED.value,
|
|
123
|
+
blocked_by_ticket_id=None,
|
|
124
|
+
)
|
|
125
|
+
db.add(ticket)
|
|
126
|
+
db.commit()
|
|
127
|
+
|
|
128
|
+
count = _unblock_ready_tickets_sync(db)
|
|
129
|
+
|
|
130
|
+
assert count == 0
|
|
131
|
+
db.refresh(ticket)
|
|
132
|
+
assert ticket.state == TicketState.BLOCKED.value
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_unblock_multiple_tickets():
|
|
136
|
+
"""Multiple tickets blocked by the same done ticket should all unblock."""
|
|
137
|
+
from app.services.planner_tick_sync import _unblock_ready_tickets_sync
|
|
138
|
+
|
|
139
|
+
db = _make_sync_session()
|
|
140
|
+
board = Board(id=str(uuid.uuid4()), name="test", repo_root="/tmp/repo")
|
|
141
|
+
db.add(board)
|
|
142
|
+
db.flush()
|
|
143
|
+
|
|
144
|
+
goal = Goal(id=str(uuid.uuid4()), board_id=board.id, title="Test goal")
|
|
145
|
+
db.add(goal)
|
|
146
|
+
db.flush()
|
|
147
|
+
|
|
148
|
+
blocker = Ticket(
|
|
149
|
+
id=str(uuid.uuid4()),
|
|
150
|
+
board_id=board.id,
|
|
151
|
+
goal_id=goal.id,
|
|
152
|
+
title="Blocker",
|
|
153
|
+
state=TicketState.DONE.value,
|
|
154
|
+
)
|
|
155
|
+
db.add(blocker)
|
|
156
|
+
db.flush()
|
|
157
|
+
|
|
158
|
+
blocked_1 = Ticket(
|
|
159
|
+
id=str(uuid.uuid4()),
|
|
160
|
+
board_id=board.id,
|
|
161
|
+
goal_id=goal.id,
|
|
162
|
+
title="Blocked 1",
|
|
163
|
+
state=TicketState.BLOCKED.value,
|
|
164
|
+
blocked_by_ticket_id=blocker.id,
|
|
165
|
+
)
|
|
166
|
+
blocked_2 = Ticket(
|
|
167
|
+
id=str(uuid.uuid4()),
|
|
168
|
+
board_id=board.id,
|
|
169
|
+
goal_id=goal.id,
|
|
170
|
+
title="Blocked 2",
|
|
171
|
+
state=TicketState.BLOCKED.value,
|
|
172
|
+
blocked_by_ticket_id=blocker.id,
|
|
173
|
+
)
|
|
174
|
+
db.add_all([blocked_1, blocked_2])
|
|
175
|
+
db.commit()
|
|
176
|
+
|
|
177
|
+
count = _unblock_ready_tickets_sync(db)
|
|
178
|
+
|
|
179
|
+
assert count == 2
|
|
180
|
+
db.refresh(blocked_1)
|
|
181
|
+
db.refresh(blocked_2)
|
|
182
|
+
assert blocked_1.state == TicketState.PLANNED.value
|
|
183
|
+
assert blocked_2.state == TicketState.PLANNED.value
|