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.
Files changed (250) hide show
  1. package/app/backend/.env.example +9 -0
  2. package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_patch.txt +195 -0
  3. package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_stat.txt +6 -0
  4. package/app/backend/CURL_EXAMPLES.md +335 -0
  5. package/app/backend/ENV_SETUP.md +65 -0
  6. package/app/backend/alembic/env.py +71 -0
  7. package/app/backend/alembic/script.py.mako +28 -0
  8. package/app/backend/alembic/versions/001_initial_schema.py +104 -0
  9. package/app/backend/alembic/versions/002_add_jobs_table.py +52 -0
  10. package/app/backend/alembic/versions/003_add_workspace_table.py +48 -0
  11. package/app/backend/alembic/versions/004_add_evidence_table.py +56 -0
  12. package/app/backend/alembic/versions/005_add_verification_commands.py +32 -0
  13. package/app/backend/alembic/versions/006_add_planner_lock_table.py +39 -0
  14. package/app/backend/alembic/versions/007_add_revision_review_tables.py +126 -0
  15. package/app/backend/alembic/versions/008_add_revision_idempotency_and_traceability.py +52 -0
  16. package/app/backend/alembic/versions/009_add_job_health_fields.py +46 -0
  17. package/app/backend/alembic/versions/010_add_review_comment_line_content.py +36 -0
  18. package/app/backend/alembic/versions/011_add_analysis_cache.py +47 -0
  19. package/app/backend/alembic/versions/012_add_boards_table.py +102 -0
  20. package/app/backend/alembic/versions/013_add_ticket_blocking.py +45 -0
  21. package/app/backend/alembic/versions/014_add_agent_sessions.py +220 -0
  22. package/app/backend/alembic/versions/015_add_ticket_sort_order.py +33 -0
  23. package/app/backend/alembic/versions/03220f0b93ae_add_pr_fields_to_ticket.py +49 -0
  24. package/app/backend/alembic/versions/0c2d89fff3b1_seed_board_configs_from_yaml.py +206 -0
  25. package/app/backend/alembic/versions/3348e5cf54c1_add_merge_checklist_table.py +67 -0
  26. package/app/backend/alembic/versions/357c780ee445_add_goal_status.py +34 -0
  27. package/app/backend/alembic/versions/553340b7e26c_add_autonomy_fields_to_goal.py +65 -0
  28. package/app/backend/alembic/versions/774dc335c679_merge_migration_heads.py +23 -0
  29. package/app/backend/alembic/versions/7b307e847cbd_merge_heads.py +23 -0
  30. package/app/backend/alembic/versions/82ecd978cc70_add_missing_indexes.py +48 -0
  31. package/app/backend/alembic/versions/8ef5054dc280_add_normalized_log_entries.py +173 -0
  32. package/app/backend/alembic/versions/8f3e2bd8ea3b_merge_migration_heads.py +23 -0
  33. package/app/backend/alembic/versions/9d17f0698d3b_add_config_column_to_boards_table.py +30 -0
  34. package/app/backend/alembic/versions/add_agent_conversation_history.py +72 -0
  35. package/app/backend/alembic/versions/add_job_variant.py +34 -0
  36. package/app/backend/alembic/versions/add_performance_indexes.py +95 -0
  37. package/app/backend/alembic/versions/add_repos_and_board_repos.py +174 -0
  38. package/app/backend/alembic/versions/add_session_id_to_jobs.py +27 -0
  39. package/app/backend/alembic/versions/add_sqlite_backend_tables.py +104 -0
  40. package/app/backend/alembic/versions/b10fb0b62240_add_diff_content_to_revisions.py +34 -0
  41. package/app/backend/alembic.ini +89 -0
  42. package/app/backend/app/__init__.py +3 -0
  43. package/app/backend/app/data_dir.py +85 -0
  44. package/app/backend/app/database.py +70 -0
  45. package/app/backend/app/database_sync.py +64 -0
  46. package/app/backend/app/dependencies/__init__.py +5 -0
  47. package/app/backend/app/dependencies/auth.py +80 -0
  48. package/app/backend/app/dependencies.py +43 -0
  49. package/app/backend/app/exceptions.py +178 -0
  50. package/app/backend/app/executors/__init__.py +1 -0
  51. package/app/backend/app/executors/adapters/__init__.py +1 -0
  52. package/app/backend/app/executors/adapters/aider.py +152 -0
  53. package/app/backend/app/executors/adapters/amazon_q.py +103 -0
  54. package/app/backend/app/executors/adapters/amp.py +123 -0
  55. package/app/backend/app/executors/adapters/claude.py +177 -0
  56. package/app/backend/app/executors/adapters/cline.py +127 -0
  57. package/app/backend/app/executors/adapters/codex.py +167 -0
  58. package/app/backend/app/executors/adapters/copilot.py +202 -0
  59. package/app/backend/app/executors/adapters/cursor.py +87 -0
  60. package/app/backend/app/executors/adapters/droid.py +123 -0
  61. package/app/backend/app/executors/adapters/gemini.py +132 -0
  62. package/app/backend/app/executors/adapters/goose.py +131 -0
  63. package/app/backend/app/executors/adapters/opencode.py +123 -0
  64. package/app/backend/app/executors/adapters/qwen.py +123 -0
  65. package/app/backend/app/executors/plugins/__init__.py +1 -0
  66. package/app/backend/app/executors/registry.py +202 -0
  67. package/app/backend/app/executors/spec.py +226 -0
  68. package/app/backend/app/main.py +486 -0
  69. package/app/backend/app/middleware/__init__.py +13 -0
  70. package/app/backend/app/middleware/idempotency.py +426 -0
  71. package/app/backend/app/middleware/rate_limit.py +312 -0
  72. package/app/backend/app/middleware/security_headers.py +43 -0
  73. package/app/backend/app/middleware/timeout.py +37 -0
  74. package/app/backend/app/models/__init__.py +56 -0
  75. package/app/backend/app/models/agent_conversation_history.py +56 -0
  76. package/app/backend/app/models/agent_session.py +127 -0
  77. package/app/backend/app/models/analysis_cache.py +49 -0
  78. package/app/backend/app/models/base.py +9 -0
  79. package/app/backend/app/models/board.py +79 -0
  80. package/app/backend/app/models/board_repo.py +68 -0
  81. package/app/backend/app/models/cost_budget.py +42 -0
  82. package/app/backend/app/models/enums.py +40 -0
  83. package/app/backend/app/models/evidence.py +132 -0
  84. package/app/backend/app/models/goal.py +102 -0
  85. package/app/backend/app/models/idempotency_entry.py +30 -0
  86. package/app/backend/app/models/job.py +163 -0
  87. package/app/backend/app/models/job_queue.py +39 -0
  88. package/app/backend/app/models/kv_store.py +28 -0
  89. package/app/backend/app/models/merge_checklist.py +87 -0
  90. package/app/backend/app/models/normalized_log.py +100 -0
  91. package/app/backend/app/models/planner_lock.py +43 -0
  92. package/app/backend/app/models/rate_limit_entry.py +25 -0
  93. package/app/backend/app/models/repo.py +66 -0
  94. package/app/backend/app/models/review_comment.py +91 -0
  95. package/app/backend/app/models/review_summary.py +69 -0
  96. package/app/backend/app/models/revision.py +130 -0
  97. package/app/backend/app/models/ticket.py +223 -0
  98. package/app/backend/app/models/ticket_event.py +83 -0
  99. package/app/backend/app/models/user.py +47 -0
  100. package/app/backend/app/models/workspace.py +71 -0
  101. package/app/backend/app/redis_client.py +119 -0
  102. package/app/backend/app/routers/__init__.py +29 -0
  103. package/app/backend/app/routers/agents.py +296 -0
  104. package/app/backend/app/routers/auth.py +94 -0
  105. package/app/backend/app/routers/board.py +885 -0
  106. package/app/backend/app/routers/dashboard.py +351 -0
  107. package/app/backend/app/routers/debug.py +528 -0
  108. package/app/backend/app/routers/evidence.py +96 -0
  109. package/app/backend/app/routers/executors.py +324 -0
  110. package/app/backend/app/routers/goals.py +574 -0
  111. package/app/backend/app/routers/jobs.py +448 -0
  112. package/app/backend/app/routers/maintenance.py +172 -0
  113. package/app/backend/app/routers/merge.py +360 -0
  114. package/app/backend/app/routers/planner.py +537 -0
  115. package/app/backend/app/routers/pull_requests.py +382 -0
  116. package/app/backend/app/routers/repos.py +263 -0
  117. package/app/backend/app/routers/revisions.py +939 -0
  118. package/app/backend/app/routers/settings.py +267 -0
  119. package/app/backend/app/routers/tickets.py +2003 -0
  120. package/app/backend/app/routers/webhooks.py +143 -0
  121. package/app/backend/app/routers/websocket.py +249 -0
  122. package/app/backend/app/schemas/__init__.py +109 -0
  123. package/app/backend/app/schemas/board.py +87 -0
  124. package/app/backend/app/schemas/common.py +33 -0
  125. package/app/backend/app/schemas/evidence.py +87 -0
  126. package/app/backend/app/schemas/goal.py +90 -0
  127. package/app/backend/app/schemas/job.py +97 -0
  128. package/app/backend/app/schemas/merge.py +139 -0
  129. package/app/backend/app/schemas/planner.py +500 -0
  130. package/app/backend/app/schemas/repo.py +187 -0
  131. package/app/backend/app/schemas/review.py +137 -0
  132. package/app/backend/app/schemas/revision.py +114 -0
  133. package/app/backend/app/schemas/ticket.py +238 -0
  134. package/app/backend/app/schemas/ticket_event.py +72 -0
  135. package/app/backend/app/schemas/workspace.py +19 -0
  136. package/app/backend/app/services/__init__.py +31 -0
  137. package/app/backend/app/services/agent_memory_service.py +223 -0
  138. package/app/backend/app/services/agent_registry.py +346 -0
  139. package/app/backend/app/services/agent_session_manager.py +318 -0
  140. package/app/backend/app/services/agent_session_service.py +219 -0
  141. package/app/backend/app/services/agent_tools.py +379 -0
  142. package/app/backend/app/services/auth_service.py +98 -0
  143. package/app/backend/app/services/autonomy_service.py +380 -0
  144. package/app/backend/app/services/board_repo_service.py +201 -0
  145. package/app/backend/app/services/board_service.py +326 -0
  146. package/app/backend/app/services/cleanup_service.py +1085 -0
  147. package/app/backend/app/services/config_service.py +908 -0
  148. package/app/backend/app/services/context_gatherer.py +557 -0
  149. package/app/backend/app/services/cost_tracking_service.py +293 -0
  150. package/app/backend/app/services/cursor_log_normalizer.py +536 -0
  151. package/app/backend/app/services/delivery_pipeline.py +440 -0
  152. package/app/backend/app/services/executor_service.py +634 -0
  153. package/app/backend/app/services/git_host/__init__.py +11 -0
  154. package/app/backend/app/services/git_host/factory.py +87 -0
  155. package/app/backend/app/services/git_host/github.py +270 -0
  156. package/app/backend/app/services/git_host/gitlab.py +194 -0
  157. package/app/backend/app/services/git_host/protocol.py +75 -0
  158. package/app/backend/app/services/git_merge_simple.py +346 -0
  159. package/app/backend/app/services/git_ops.py +384 -0
  160. package/app/backend/app/services/github_service.py +233 -0
  161. package/app/backend/app/services/goal_service.py +113 -0
  162. package/app/backend/app/services/job_service.py +423 -0
  163. package/app/backend/app/services/job_watchdog_service.py +424 -0
  164. package/app/backend/app/services/langchain_adapter.py +122 -0
  165. package/app/backend/app/services/llm_provider_clients.py +351 -0
  166. package/app/backend/app/services/llm_service.py +285 -0
  167. package/app/backend/app/services/log_normalizer.py +342 -0
  168. package/app/backend/app/services/log_stream_service.py +276 -0
  169. package/app/backend/app/services/merge_checklist_service.py +264 -0
  170. package/app/backend/app/services/merge_service.py +784 -0
  171. package/app/backend/app/services/orchestrator_log.py +84 -0
  172. package/app/backend/app/services/planner_service.py +1662 -0
  173. package/app/backend/app/services/planner_tick_sync.py +1040 -0
  174. package/app/backend/app/services/queued_message_service.py +156 -0
  175. package/app/backend/app/services/reliability_wrapper.py +389 -0
  176. package/app/backend/app/services/repo_discovery_service.py +318 -0
  177. package/app/backend/app/services/review_service.py +334 -0
  178. package/app/backend/app/services/revision_service.py +389 -0
  179. package/app/backend/app/services/safe_autopilot.py +510 -0
  180. package/app/backend/app/services/sqlite_worker.py +372 -0
  181. package/app/backend/app/services/task_dispatch.py +135 -0
  182. package/app/backend/app/services/ticket_generation_service.py +1781 -0
  183. package/app/backend/app/services/ticket_service.py +486 -0
  184. package/app/backend/app/services/udar_planner_service.py +1007 -0
  185. package/app/backend/app/services/webhook_service.py +126 -0
  186. package/app/backend/app/services/workspace_service.py +465 -0
  187. package/app/backend/app/services/worktree_file_service.py +92 -0
  188. package/app/backend/app/services/worktree_validator.py +213 -0
  189. package/app/backend/app/sqlite_kv.py +278 -0
  190. package/app/backend/app/state_machine.py +128 -0
  191. package/app/backend/app/templates/__init__.py +5 -0
  192. package/app/backend/app/templates/registry.py +243 -0
  193. package/app/backend/app/utils/__init__.py +5 -0
  194. package/app/backend/app/utils/artifact_reader.py +87 -0
  195. package/app/backend/app/utils/circuit_breaker.py +229 -0
  196. package/app/backend/app/utils/db_retry.py +136 -0
  197. package/app/backend/app/utils/ignored_fields.py +123 -0
  198. package/app/backend/app/utils/validators.py +54 -0
  199. package/app/backend/app/websocket/__init__.py +5 -0
  200. package/app/backend/app/websocket/manager.py +179 -0
  201. package/app/backend/app/websocket/state_tracker.py +113 -0
  202. package/app/backend/app/worker.py +3190 -0
  203. package/app/backend/calculator_tickets.json +40 -0
  204. package/app/backend/canary_tests.sh +591 -0
  205. package/app/backend/celerybeat-schedule +0 -0
  206. package/app/backend/celerybeat-schedule-shm +0 -0
  207. package/app/backend/celerybeat-schedule-wal +0 -0
  208. package/app/backend/logs/.gitkeep +3 -0
  209. package/app/backend/multiplication_division_implementation_tickets.json +55 -0
  210. package/app/backend/multiplication_division_tickets.json +42 -0
  211. package/app/backend/pyproject.toml +45 -0
  212. package/app/backend/requirements-dev.txt +8 -0
  213. package/app/backend/requirements.txt +20 -0
  214. package/app/backend/run.sh +30 -0
  215. package/app/backend/run_with_logs.sh +10 -0
  216. package/app/backend/scientific_calculator_tickets.json +40 -0
  217. package/app/backend/scripts/extract_openapi.py +21 -0
  218. package/app/backend/scripts/seed_demo.py +187 -0
  219. package/app/backend/setup_demo_review.py +302 -0
  220. package/app/backend/test_actual_parse.py +41 -0
  221. package/app/backend/test_agent_streaming.py +61 -0
  222. package/app/backend/test_parse.py +51 -0
  223. package/app/backend/test_streaming.py +51 -0
  224. package/app/backend/test_subprocess_streaming.py +50 -0
  225. package/app/backend/tests/__init__.py +1 -0
  226. package/app/backend/tests/conftest.py +46 -0
  227. package/app/backend/tests/test_auth.py +341 -0
  228. package/app/backend/tests/test_autonomy_service.py +391 -0
  229. package/app/backend/tests/test_cleanup_service_safety.py +417 -0
  230. package/app/backend/tests/test_middleware.py +279 -0
  231. package/app/backend/tests/test_planner_providers.py +290 -0
  232. package/app/backend/tests/test_planner_unblock.py +183 -0
  233. package/app/backend/tests/test_revision_invariants.py +618 -0
  234. package/app/backend/tests/test_sqlite_kv.py +290 -0
  235. package/app/backend/tests/test_sqlite_worker.py +353 -0
  236. package/app/backend/tests/test_task_dispatch.py +100 -0
  237. package/app/backend/tests/test_ticket_validation.py +304 -0
  238. package/app/backend/tests/test_udar_agent.py +693 -0
  239. package/app/backend/tests/test_webhook_service.py +184 -0
  240. package/app/backend/tickets_output.json +59 -0
  241. package/app/backend/user_management_tickets.json +50 -0
  242. package/app/backend/uvicorn.log +0 -0
  243. package/app/draft.yaml +313 -0
  244. package/app/frontend/dist/assets/index-LcjCczu5.js +155 -0
  245. package/app/frontend/dist/assets/index-_FP_279e.css +1 -0
  246. package/app/frontend/dist/index.html +14 -0
  247. package/app/frontend/dist/vite.svg +1 -0
  248. package/app/frontend/package.json +101 -0
  249. package/bin/cli.js +527 -0
  250. 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