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,885 @@
1
+ """API router for Board endpoints."""
2
+
3
+ from pathlib import Path
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException, status
6
+ from sqlalchemy import select
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+ from sqlalchemy.orm import selectinload
9
+
10
+ from app.database import get_db
11
+ from app.dependencies.auth import get_current_user
12
+ from app.models.user import User
13
+ from app.schemas.board import (
14
+ BoardConfigResponse,
15
+ BoardConfigUpdate,
16
+ BoardCreate,
17
+ BoardListResponse,
18
+ BoardResponse,
19
+ BoardUpdate,
20
+ )
21
+ from app.schemas.planner import AnalyzeCodebaseRequest, AnalyzeCodebaseResponse
22
+ from app.schemas.repo import (
23
+ BoardRepoCreate,
24
+ BoardRepoListResponse,
25
+ BoardRepoResponse,
26
+ BoardRepoUpdate,
27
+ )
28
+ from app.schemas.ticket import BoardResponse as KanbanBoardResponse
29
+ from app.services.board_repo_service import BoardRepoService
30
+ from app.services.board_service import BoardService
31
+ from app.services.config_service import ConfigService
32
+ from app.services.ticket_generation_service import TicketGenerationService
33
+ from app.services.ticket_service import TicketService
34
+ from app.templates import list_templates
35
+
36
+ router = APIRouter(prefix="/boards", tags=["boards"])
37
+
38
+
39
+ # ============================================================================
40
+ # Template endpoints
41
+ # ============================================================================
42
+
43
+
44
+ @router.get(
45
+ "/templates",
46
+ summary="List available project templates",
47
+ )
48
+ async def get_templates():
49
+ """Get all available project templates.
50
+
51
+ Templates provide pre-configured board settings and starter goals
52
+ for common project types like web apps, APIs, mobile apps, etc.
53
+
54
+ Use the template_id when creating a board to apply a template.
55
+ """
56
+ templates = list_templates()
57
+ return {"templates": templates, "total": len(templates)}
58
+
59
+
60
+ # ============================================================================
61
+ # Board CRUD endpoints
62
+ # ============================================================================
63
+
64
+
65
+ @router.post(
66
+ "",
67
+ response_model=BoardResponse,
68
+ status_code=status.HTTP_201_CREATED,
69
+ summary="Create a new board",
70
+ )
71
+ async def create_board(
72
+ data: BoardCreate,
73
+ db: AsyncSession = Depends(get_db),
74
+ current_user: User | None = Depends(get_current_user),
75
+ ) -> BoardResponse:
76
+ """Create a new board with a repository root.
77
+
78
+ **Important:** The repo_root must be an absolute path to an existing
79
+ git repository. This becomes the authoritative path for all file
80
+ operations on this board.
81
+
82
+ When auth is enabled, the board is owned by the authenticated user.
83
+ """
84
+ service = BoardService(db)
85
+ try:
86
+ board = await service.create_board(
87
+ data, owner_id=current_user.id if current_user else None
88
+ )
89
+ return BoardResponse.model_validate(board)
90
+ except ValueError as e:
91
+ raise HTTPException(status_code=400, detail=str(e))
92
+
93
+
94
+ @router.get(
95
+ "",
96
+ response_model=BoardListResponse,
97
+ summary="List all boards",
98
+ )
99
+ async def list_boards(
100
+ db: AsyncSession = Depends(get_db),
101
+ current_user: User | None = Depends(get_current_user),
102
+ ) -> BoardListResponse:
103
+ """Get all boards.
104
+
105
+ When auth is enabled, returns only boards owned by the authenticated user.
106
+ When auth is disabled, returns all boards (backward compatible).
107
+ """
108
+ service = BoardService(db)
109
+ owner_id = current_user.id if current_user is not None else None
110
+ boards = await service.get_boards(owner_id=owner_id)
111
+ return BoardListResponse(
112
+ boards=[BoardResponse.model_validate(b) for b in boards],
113
+ total=len(boards),
114
+ )
115
+
116
+
117
+ @router.get(
118
+ "/{board_id}",
119
+ response_model=BoardResponse,
120
+ summary="Get a board by ID",
121
+ )
122
+ async def get_board(
123
+ board_id: str,
124
+ db: AsyncSession = Depends(get_db),
125
+ ) -> BoardResponse:
126
+ """Get a board by its ID."""
127
+ service = BoardService(db)
128
+ try:
129
+ board = await service.get_board_by_id(board_id)
130
+ return BoardResponse.model_validate(board)
131
+ except ValueError as e:
132
+ raise HTTPException(status_code=404, detail=str(e))
133
+
134
+
135
+ @router.patch(
136
+ "/{board_id}",
137
+ response_model=BoardResponse,
138
+ summary="Update a board",
139
+ )
140
+ async def update_board(
141
+ board_id: str,
142
+ data: BoardUpdate,
143
+ db: AsyncSession = Depends(get_db),
144
+ ) -> BoardResponse:
145
+ """Update a board's name, description, or default branch."""
146
+ service = BoardService(db)
147
+ try:
148
+ board = await service.update_board(board_id, data)
149
+ return BoardResponse.model_validate(board)
150
+ except ValueError as e:
151
+ raise HTTPException(status_code=404, detail=str(e))
152
+
153
+
154
+ @router.delete(
155
+ "/{board_id}",
156
+ status_code=status.HTTP_204_NO_CONTENT,
157
+ summary="Delete a board",
158
+ )
159
+ async def delete_board(
160
+ board_id: str,
161
+ db: AsyncSession = Depends(get_db),
162
+ ) -> None:
163
+ """Delete a board and all its associated goals, tickets, jobs, workspaces."""
164
+ service = BoardService(db)
165
+ try:
166
+ await service.delete_board(board_id)
167
+ except ValueError as e:
168
+ raise HTTPException(status_code=404, detail=str(e))
169
+
170
+
171
+ # ============================================================================
172
+ # Board Configuration endpoints
173
+ # ============================================================================
174
+
175
+
176
+ @router.get(
177
+ "/{board_id}/config",
178
+ response_model=BoardConfigResponse,
179
+ summary="Get board configuration",
180
+ )
181
+ async def get_board_config(
182
+ board_id: str,
183
+ db: AsyncSession = Depends(get_db),
184
+ ) -> BoardConfigResponse:
185
+ """
186
+ Get the board-level configuration overrides.
187
+
188
+ Returns the raw config JSON stored in the board, which overrides
189
+ settings from draft.yaml in the repository.
190
+
191
+ Configuration priority (highest to lowest):
192
+ 1. Board config (this endpoint)
193
+ 2. YAML config (draft.yaml)
194
+ 3. Defaults
195
+ """
196
+ service = BoardService(db)
197
+ try:
198
+ board = await service.get_board_by_id(board_id)
199
+ return BoardConfigResponse(
200
+ board_id=board.id,
201
+ config=board.config,
202
+ has_overrides=board.config is not None and len(board.config) > 0,
203
+ )
204
+ except ValueError as e:
205
+ raise HTTPException(status_code=404, detail=str(e))
206
+
207
+
208
+ @router.put(
209
+ "/{board_id}/config",
210
+ response_model=BoardConfigResponse,
211
+ summary="Update board configuration",
212
+ )
213
+ async def update_board_config(
214
+ board_id: str,
215
+ data: BoardConfigUpdate,
216
+ db: AsyncSession = Depends(get_db),
217
+ ) -> BoardConfigResponse:
218
+ """
219
+ Update board-level configuration overrides.
220
+
221
+ This performs a **partial deep merge** - only provided fields are updated,
222
+ nested objects are merged (not replaced).
223
+
224
+ Example:
225
+ Existing config: {"execute_config": {"timeout": 600, "executor_model": "opus"}}
226
+ Update: {"execute_config": {"executor_model": "auto"}}
227
+ Result: {"execute_config": {"timeout": 600, "executor_model": "auto"}}
228
+
229
+ To clear a specific field, set it to null in the request.
230
+ To clear all overrides, use DELETE /boards/{board_id}/config.
231
+ """
232
+ service = BoardService(db)
233
+ try:
234
+ board = await service.get_board_by_id(board_id)
235
+
236
+ # Convert Pydantic model to dict, excluding None values
237
+ update_dict = data.model_dump(exclude_none=True)
238
+
239
+ if not update_dict:
240
+ # No updates provided
241
+ return BoardConfigResponse(
242
+ board_id=board.id,
243
+ config=board.config,
244
+ has_overrides=board.config is not None and len(board.config) > 0,
245
+ )
246
+
247
+ # Deep merge with existing config
248
+ from app.services.config_service import deep_merge_dicts
249
+
250
+ existing_config = board.config or {}
251
+ merged_config = deep_merge_dicts(existing_config, update_dict)
252
+
253
+ # Update board
254
+ from app.schemas.board import BoardUpdate
255
+
256
+ update_data = BoardUpdate(config=merged_config)
257
+ board = await service.update_board(board_id, update_data)
258
+
259
+ return BoardConfigResponse(
260
+ board_id=board.id,
261
+ config=board.config,
262
+ has_overrides=True,
263
+ )
264
+ except ValueError as e:
265
+ raise HTTPException(status_code=404, detail=str(e))
266
+
267
+
268
+ @router.post(
269
+ "/{board_id}/config/import-yaml",
270
+ response_model=BoardConfigResponse,
271
+ summary="Import configuration from draft.yaml",
272
+ )
273
+ async def import_yaml_config(
274
+ board_id: str,
275
+ db: AsyncSession = Depends(get_db),
276
+ ) -> BoardConfigResponse:
277
+ """
278
+ One-time import: reads draft.yaml from the board's repo_root,
279
+ deep-merges it with the existing board config, and saves to DB.
280
+
281
+ After this, the board's DB config is the single source of truth.
282
+ """
283
+ service = BoardService(db)
284
+ try:
285
+ board = await service.get_board_by_id(board_id)
286
+ except ValueError as e:
287
+ raise HTTPException(status_code=404, detail=str(e))
288
+
289
+ repo_root = Path(board.repo_root).resolve()
290
+ yaml_path = repo_root / "draft.yaml"
291
+
292
+ if not yaml_path.exists():
293
+ raise HTTPException(
294
+ status_code=404,
295
+ detail=f"draft.yaml not found at {repo_root}",
296
+ )
297
+
298
+ import yaml
299
+
300
+ from app.services.config_service import DraftConfig, deep_merge_dicts
301
+
302
+ try:
303
+ with open(yaml_path) as f:
304
+ yaml_data = yaml.safe_load(f) or {}
305
+ except Exception as e:
306
+ raise HTTPException(
307
+ status_code=400,
308
+ detail=f"Failed to parse draft.yaml: {e}",
309
+ )
310
+
311
+ # Build: defaults <- yaml <- existing board config
312
+ defaults = DraftConfig().to_dict()
313
+ merged = deep_merge_dicts(defaults, yaml_data)
314
+ if board.config:
315
+ merged = deep_merge_dicts(merged, board.config)
316
+
317
+ from app.schemas.board import BoardUpdate
318
+
319
+ board = await service.update_board(board_id, BoardUpdate(config=merged))
320
+
321
+ return BoardConfigResponse(
322
+ board_id=board.id,
323
+ config=board.config,
324
+ has_overrides=True,
325
+ )
326
+
327
+
328
+ @router.delete(
329
+ "/{board_id}/config",
330
+ status_code=status.HTTP_204_NO_CONTENT,
331
+ summary="Clear board configuration",
332
+ )
333
+ async def clear_board_config(
334
+ board_id: str,
335
+ db: AsyncSession = Depends(get_db),
336
+ ) -> None:
337
+ """
338
+ Clear all board-level configuration overrides.
339
+
340
+ After this, the board will use settings from draft.yaml only.
341
+ """
342
+ service = BoardService(db)
343
+ try:
344
+ from app.schemas.board import BoardUpdate
345
+
346
+ await service.update_board(board_id, BoardUpdate(config=None))
347
+ except ValueError as e:
348
+ raise HTTPException(status_code=404, detail=str(e))
349
+
350
+
351
+ @router.post(
352
+ "/{board_id}/config/initialize",
353
+ response_model=BoardConfigResponse,
354
+ summary="Initialize board configuration with defaults",
355
+ )
356
+ async def initialize_board_config(
357
+ board_id: str,
358
+ db: AsyncSession = Depends(get_db),
359
+ ) -> BoardConfigResponse:
360
+ """
361
+ Initialize board configuration with sensible defaults if config is null.
362
+
363
+ This is useful for boards created before auto-initialization was implemented.
364
+ If the board already has config, this is a no-op.
365
+
366
+ Default config includes:
367
+ - executor_model: "sonnet-4.5" (fast and cost-effective)
368
+ - timeout: 300 (5 minutes)
369
+ """
370
+ service = BoardService(db)
371
+ try:
372
+ board = await service.initialize_board_config(board_id)
373
+ return BoardConfigResponse(
374
+ board_id=board.id,
375
+ config=board.config,
376
+ has_overrides=board.config is not None and len(board.config) > 0,
377
+ )
378
+ except ValueError as e:
379
+ raise HTTPException(status_code=404, detail=str(e))
380
+
381
+
382
+ @router.post(
383
+ "/config/initialize-all",
384
+ summary="Initialize configuration for all boards with null config",
385
+ )
386
+ async def initialize_all_board_configs(
387
+ db: AsyncSession = Depends(get_db),
388
+ ):
389
+ """
390
+ Initialize configuration for all boards that have config=null.
391
+
392
+ This is a maintenance endpoint useful for migrating existing boards
393
+ to the new auto-initialization behavior.
394
+
395
+ Returns a summary of how many boards were updated.
396
+ """
397
+ service = BoardService(db)
398
+ result = await service.initialize_all_board_configs()
399
+ return result
400
+
401
+
402
+ @router.delete(
403
+ "/{board_id}/tickets",
404
+ summary="Delete all tickets from a board",
405
+ )
406
+ async def delete_all_tickets(
407
+ board_id: str,
408
+ db: AsyncSession = Depends(get_db),
409
+ ):
410
+ """
411
+ Delete all tickets from a board.
412
+
413
+ **WARNING:** This action cannot be undone!
414
+
415
+ This will cascade delete all associated:
416
+ - Jobs
417
+ - Revisions (and their review comments/summaries)
418
+ - Ticket events
419
+ - Workspaces
420
+ - Evidence files
421
+
422
+ Worktrees are cleaned up asynchronously (best effort).
423
+ """
424
+ # Verify board exists
425
+ board_service = BoardService(db)
426
+ try:
427
+ await board_service.get_board_by_id(board_id)
428
+ except ValueError as e:
429
+ raise HTTPException(status_code=404, detail=str(e))
430
+
431
+ # Delete all tickets for this board
432
+ ticket_service = TicketService(db)
433
+ count = await ticket_service.delete_all_tickets(board_id=board_id)
434
+
435
+ return {
436
+ "deleted_count": count,
437
+ "message": f"Deleted {count} ticket(s) from board {board_id}",
438
+ }
439
+
440
+
441
+ @router.get(
442
+ "/{board_id}/board",
443
+ response_model=KanbanBoardResponse,
444
+ summary="Get the kanban board view for a specific board",
445
+ )
446
+ async def get_board_kanban(
447
+ board_id: str,
448
+ db: AsyncSession = Depends(get_db),
449
+ ) -> KanbanBoardResponse:
450
+ """
451
+ Get the kanban board view with all tickets for this board grouped by state.
452
+
453
+ Returns tickets organized into columns by state, ordered by priority
454
+ (highest first) within each column.
455
+
456
+ Only tickets belonging to this board are included.
457
+ """
458
+ # Verify board exists
459
+ board_service = BoardService(db)
460
+ try:
461
+ await board_service.get_board_by_id(board_id)
462
+ except ValueError as e:
463
+ raise HTTPException(status_code=404, detail=str(e))
464
+
465
+ # Get tickets for this board
466
+ service = TicketService(db)
467
+ columns = await service.get_board(board_id=board_id)
468
+
469
+ # Count total tickets (conversion already handled in service)
470
+ total_tickets = sum(len(column.tickets) for column in columns)
471
+
472
+ return KanbanBoardResponse(
473
+ columns=columns,
474
+ total_tickets=total_tickets,
475
+ )
476
+
477
+
478
+ @router.get(
479
+ "/{board_id}/export",
480
+ summary="Export all board data as JSON",
481
+ )
482
+ async def export_board(
483
+ board_id: str,
484
+ db: AsyncSession = Depends(get_db),
485
+ ):
486
+ """
487
+ Export the full board data including goals, tickets, jobs, and events.
488
+
489
+ Returns a JSON object containing:
490
+ - `board`: Board metadata
491
+ - `goals`: All goals for the board
492
+ - `tickets`: All tickets with their events
493
+ - `jobs`: All jobs for this board's tickets
494
+
495
+ **Use cases:**
496
+ - Backup/archive a board
497
+ - Transfer board data between instances
498
+ - Generate reports
499
+ """
500
+ from app.models.goal import Goal
501
+ from app.models.job import Job
502
+ from app.models.ticket import Ticket
503
+
504
+ # Verify board exists
505
+ board_service_inst = BoardService(db)
506
+ try:
507
+ board = await board_service_inst.get_board_by_id(board_id)
508
+ except ValueError as e:
509
+ raise HTTPException(status_code=404, detail=str(e))
510
+
511
+ # Get goals
512
+ goals_result = await db.execute(
513
+ select(Goal).where(Goal.board_id == board_id).order_by(Goal.created_at.asc())
514
+ )
515
+ goals = list(goals_result.scalars().all())
516
+
517
+ # Get tickets with events eager-loaded
518
+ tickets_result = await db.execute(
519
+ select(Ticket)
520
+ .where(Ticket.board_id == board_id)
521
+ .options(selectinload(Ticket.events))
522
+ .order_by(Ticket.created_at.asc())
523
+ )
524
+ tickets = list(tickets_result.scalars().all())
525
+
526
+ # Get jobs
527
+ jobs_result = await db.execute(
528
+ select(Job).where(Job.board_id == board_id).order_by(Job.created_at.asc())
529
+ )
530
+ jobs = list(jobs_result.scalars().all())
531
+
532
+ return {
533
+ "board": {
534
+ "id": board.id,
535
+ "name": board.name,
536
+ "description": board.description,
537
+ "repo_root": board.repo_root,
538
+ "default_branch": board.default_branch,
539
+ "config": board.config,
540
+ "created_at": board.created_at.isoformat() if board.created_at else None,
541
+ "updated_at": board.updated_at.isoformat() if board.updated_at else None,
542
+ },
543
+ "goals": [
544
+ {
545
+ "id": g.id,
546
+ "title": g.title,
547
+ "description": g.description,
548
+ "autonomy_enabled": g.autonomy_enabled,
549
+ "created_at": g.created_at.isoformat() if g.created_at else None,
550
+ "updated_at": g.updated_at.isoformat() if g.updated_at else None,
551
+ }
552
+ for g in goals
553
+ ],
554
+ "tickets": [
555
+ {
556
+ "id": t.id,
557
+ "goal_id": t.goal_id,
558
+ "title": t.title,
559
+ "description": t.description,
560
+ "state": t.state,
561
+ "priority": t.priority,
562
+ "sort_order": t.sort_order if hasattr(t, "sort_order") else None,
563
+ "blocked_by_ticket_id": t.blocked_by_ticket_id,
564
+ "created_at": t.created_at.isoformat() if t.created_at else None,
565
+ "updated_at": t.updated_at.isoformat() if t.updated_at else None,
566
+ "events": [
567
+ {
568
+ "id": e.id,
569
+ "event_type": e.event_type,
570
+ "from_state": e.from_state,
571
+ "to_state": e.to_state,
572
+ "actor_type": e.actor_type,
573
+ "reason": e.reason,
574
+ "created_at": e.created_at.isoformat()
575
+ if e.created_at
576
+ else None,
577
+ }
578
+ for e in (t.events or [])
579
+ ],
580
+ }
581
+ for t in tickets
582
+ ],
583
+ "jobs": [
584
+ {
585
+ "id": j.id,
586
+ "ticket_id": j.ticket_id,
587
+ "kind": j.kind,
588
+ "status": j.status,
589
+ "exit_code": j.exit_code,
590
+ "created_at": j.created_at.isoformat() if j.created_at else None,
591
+ "started_at": j.started_at.isoformat() if j.started_at else None,
592
+ "finished_at": j.finished_at.isoformat() if j.finished_at else None,
593
+ }
594
+ for j in jobs
595
+ ],
596
+ }
597
+
598
+
599
+ # ============================================================================
600
+ # Board-scoped operations (use board's repo_root, NOT client-provided paths)
601
+ # ============================================================================
602
+
603
+
604
+ @router.post(
605
+ "/{board_id}/analyze-codebase",
606
+ response_model=AnalyzeCodebaseResponse,
607
+ summary="Analyze codebase and generate improvement tickets",
608
+ )
609
+ async def analyze_codebase(
610
+ board_id: str,
611
+ request: AnalyzeCodebaseRequest,
612
+ db: AsyncSession = Depends(get_db),
613
+ ) -> AnalyzeCodebaseResponse:
614
+ """
615
+ Analyze the board's repository codebase and generate improvement tickets.
616
+
617
+ **Security:**
618
+ - Repository path is taken from the board's repo_root, NOT from client request
619
+ - Sensitive files (.env, keys, secrets) are automatically excluded
620
+ - Only metadata and small excerpts are sent to the LLM
621
+
622
+ **Caching:**
623
+ - Results are cached for 10 minutes to avoid expensive repeated LLM calls
624
+ - `cache_hit: true` in response indicates cached result
625
+
626
+ **Focus Areas (optional):**
627
+ - `security`: Look for security issues
628
+ - `performance`: Look for performance problems
629
+ - `tests`: Look for missing tests
630
+ - `docs`: Look for documentation gaps
631
+
632
+ **Goal attachment:**
633
+ - If `goal_id` is provided, tickets are created in the database
634
+ - If `goal_id` is omitted, returns preview only (no DB write)
635
+ - **Goal must belong to this board** (board_id check enforced)
636
+
637
+ **Tickets include priority buckets:**
638
+ - P0 (90): Critical - security, data loss
639
+ - P1 (70): High - important features, performance
640
+ - P2 (50): Medium - improvements
641
+ - P3 (30): Low - cleanup, docs
642
+ """
643
+ # Get repo_root from board (authoritative source)
644
+ board_service = BoardService(db)
645
+ try:
646
+ repo_root = await board_service.get_repo_root(board_id)
647
+ except ValueError as e:
648
+ raise HTTPException(status_code=404, detail=str(e))
649
+
650
+ if not repo_root.exists():
651
+ raise HTTPException(
652
+ status_code=500,
653
+ detail=f"Board's repo_root does not exist: {repo_root}",
654
+ )
655
+
656
+ # If goal_id provided, verify it belongs to this board
657
+ if request.goal_id:
658
+ from sqlalchemy import select
659
+
660
+ from app.models import Goal
661
+
662
+ result = await db.execute(select(Goal).where(Goal.id == request.goal_id))
663
+ goal = result.scalar_one_or_none()
664
+ if not goal:
665
+ raise HTTPException(
666
+ status_code=404, detail=f"Goal not found: {request.goal_id}"
667
+ )
668
+ if goal.board_id and goal.board_id != board_id:
669
+ raise HTTPException(
670
+ status_code=403,
671
+ detail=f"Goal {request.goal_id} belongs to board {goal.board_id}, not {board_id}",
672
+ )
673
+
674
+ service = TicketGenerationService(db)
675
+ try:
676
+ return await service.analyze_codebase(
677
+ repo_root=repo_root,
678
+ goal_id=request.goal_id,
679
+ focus_areas=request.focus_areas,
680
+ include_readme=request.include_readme,
681
+ board_id=board_id, # Pass board_id for context/caching
682
+ )
683
+ except ValueError as e:
684
+ raise HTTPException(status_code=400, detail=str(e))
685
+
686
+
687
+ # ============================================================================
688
+ # Board-Repo Association endpoints
689
+ # ============================================================================
690
+
691
+
692
+ @router.get(
693
+ "/{board_id}/repos",
694
+ response_model=BoardRepoListResponse,
695
+ summary="Get repos for a board",
696
+ )
697
+ async def get_board_repos(
698
+ board_id: str,
699
+ db: AsyncSession = Depends(get_db),
700
+ ) -> BoardRepoListResponse:
701
+ """
702
+ Get all repositories associated with a board.
703
+
704
+ Returns repos ordered by:
705
+ 1. Primary repos first
706
+ 2. Then by creation date (oldest first)
707
+ """
708
+ # Verify board exists
709
+ board_service = BoardService(db)
710
+ try:
711
+ await board_service.get_board_by_id(board_id)
712
+ except ValueError as e:
713
+ raise HTTPException(status_code=404, detail=str(e))
714
+
715
+ # Get board repos
716
+ board_repo_service = BoardRepoService(db)
717
+ board_repos = await board_repo_service.get_board_repos(board_id)
718
+
719
+ return BoardRepoListResponse(
720
+ board_id=board_id,
721
+ repos=[BoardRepoResponse.model_validate(br) for br in board_repos],
722
+ total=len(board_repos),
723
+ )
724
+
725
+
726
+ @router.post(
727
+ "/{board_id}/repos",
728
+ response_model=BoardRepoResponse,
729
+ status_code=status.HTTP_201_CREATED,
730
+ summary="Add a repo to a board",
731
+ )
732
+ async def add_repo_to_board(
733
+ board_id: str,
734
+ data: BoardRepoCreate,
735
+ db: AsyncSession = Depends(get_db),
736
+ ) -> BoardRepoResponse:
737
+ """
738
+ Associate a repository with a board.
739
+
740
+ **Notes:**
741
+ - Repo must already be registered (use POST /repos first)
742
+ - If is_primary=true, this becomes the primary repo (others are unset)
743
+ - Custom scripts override the repo's default scripts for this board
744
+ """
745
+ board_repo_service = BoardRepoService(db)
746
+ try:
747
+ board_repo = await board_repo_service.add_repo_to_board(
748
+ board_id=board_id,
749
+ repo_id=data.repo_id,
750
+ is_primary=data.is_primary,
751
+ custom_setup_script=data.custom_setup_script,
752
+ custom_cleanup_script=data.custom_cleanup_script,
753
+ )
754
+ return BoardRepoResponse.model_validate(board_repo)
755
+ except ValueError as e:
756
+ raise HTTPException(status_code=400, detail=str(e))
757
+
758
+
759
+ @router.patch(
760
+ "/{board_id}/repos/{repo_id}",
761
+ response_model=BoardRepoResponse,
762
+ summary="Update board-repo association",
763
+ )
764
+ async def update_board_repo(
765
+ board_id: str,
766
+ repo_id: str,
767
+ data: BoardRepoUpdate,
768
+ db: AsyncSession = Depends(get_db),
769
+ ) -> BoardRepoResponse:
770
+ """
771
+ Update a board-repo association.
772
+
773
+ **Common use case:** Set a repo as primary via `is_primary: true`
774
+ """
775
+ board_repo_service = BoardRepoService(db)
776
+ try:
777
+ board_repo = await board_repo_service.update_board_repo(
778
+ board_id=board_id,
779
+ repo_id=repo_id,
780
+ is_primary=data.is_primary,
781
+ custom_setup_script=data.custom_setup_script,
782
+ custom_cleanup_script=data.custom_cleanup_script,
783
+ )
784
+ return BoardRepoResponse.model_validate(board_repo)
785
+ except ValueError as e:
786
+ raise HTTPException(status_code=404, detail=str(e))
787
+
788
+
789
+ @router.delete(
790
+ "/{board_id}/repos/{repo_id}",
791
+ status_code=status.HTTP_204_NO_CONTENT,
792
+ summary="Remove repo from board",
793
+ )
794
+ async def remove_repo_from_board(
795
+ board_id: str,
796
+ repo_id: str,
797
+ db: AsyncSession = Depends(get_db),
798
+ ) -> None:
799
+ """
800
+ Remove a repository from a board.
801
+
802
+ **Warning:** This does not delete the repo itself, only the association.
803
+ The repo remains in the global registry.
804
+ """
805
+ board_repo_service = BoardRepoService(db)
806
+ try:
807
+ await board_repo_service.remove_repo_from_board(board_id, repo_id)
808
+ except ValueError as e:
809
+ raise HTTPException(status_code=404, detail=str(e))
810
+
811
+
812
+ # ============================================================================
813
+ # Legacy kanban board view (kept for backwards compatibility)
814
+ # ============================================================================
815
+
816
+ # Create a separate router for the legacy /board endpoint
817
+ legacy_router = APIRouter(prefix="/board", tags=["board"])
818
+
819
+
820
+ @legacy_router.get(
821
+ "",
822
+ response_model=KanbanBoardResponse,
823
+ summary="Get the kanban board view",
824
+ )
825
+ async def get_kanban_board(
826
+ db: AsyncSession = Depends(get_db),
827
+ ) -> KanbanBoardResponse:
828
+ """
829
+ Get the kanban board view with all tickets grouped by state.
830
+
831
+ Returns tickets organized into columns by state, ordered by priority
832
+ (highest first) within each column.
833
+
834
+ **Note:** This is a legacy endpoint. For multi-board support, use
835
+ GET /boards/{board_id}/tickets instead.
836
+ """
837
+ service = TicketService(db)
838
+ columns = await service.get_board()
839
+
840
+ # Count total tickets (conversion already handled in service)
841
+ total_tickets = sum(len(column.tickets) for column in columns)
842
+
843
+ return KanbanBoardResponse(
844
+ columns=columns,
845
+ total_tickets=total_tickets,
846
+ )
847
+
848
+
849
+ @legacy_router.post(
850
+ "/analyze-codebase",
851
+ response_model=AnalyzeCodebaseResponse,
852
+ summary="[DEPRECATED] Analyze codebase - use /boards/{board_id}/analyze-codebase",
853
+ deprecated=True,
854
+ )
855
+ async def analyze_codebase_legacy(
856
+ request: AnalyzeCodebaseRequest,
857
+ db: AsyncSession = Depends(get_db),
858
+ ) -> AnalyzeCodebaseResponse:
859
+ """
860
+ **DEPRECATED:** Use POST /boards/{board_id}/analyze-codebase instead.
861
+
862
+ This endpoint uses the repo_root from draft.yaml config.
863
+ The board-scoped endpoint is preferred for multi-board setups.
864
+ """
865
+ # Get repo root from config - legacy path
866
+ config_service = ConfigService()
867
+ config = config_service.load_config()
868
+ repo_root = Path(config.project.repo_root).resolve()
869
+
870
+ if not repo_root.exists():
871
+ raise HTTPException(
872
+ status_code=500,
873
+ detail=f"Configured repo_root does not exist: {repo_root}",
874
+ )
875
+
876
+ service = TicketGenerationService(db)
877
+ try:
878
+ return await service.analyze_codebase(
879
+ repo_root=repo_root,
880
+ goal_id=request.goal_id,
881
+ focus_areas=request.focus_areas,
882
+ include_readme=request.include_readme,
883
+ )
884
+ except ValueError as e:
885
+ raise HTTPException(status_code=400, detail=str(e))