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,126 @@
1
+ """Webhook notification service for ticket state changes.
2
+
3
+ Sends POST requests to configured webhook URLs when ticket status changes.
4
+ Webhooks are stored in Board.config["webhooks"] as a list of webhook objects:
5
+ [{"url": "https://...", "events": ["*"], "secret": "optional-hmac-key"}]
6
+
7
+ Events:
8
+ - "ticket.transitioned" — any state change
9
+ - "*" — all events (same as above, for future extensibility)
10
+ """
11
+
12
+ import hashlib
13
+ import hmac
14
+ import json
15
+ import logging
16
+ from datetime import UTC, datetime
17
+
18
+ import httpx
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Timeout for webhook HTTP calls (connect, read)
23
+ WEBHOOK_TIMEOUT = httpx.Timeout(5.0, connect=3.0)
24
+
25
+
26
+ def _sign_payload(payload_bytes: bytes, secret: str) -> str:
27
+ """Generate HMAC-SHA256 signature for webhook payload."""
28
+ return hmac.new(secret.encode(), payload_bytes, hashlib.sha256).hexdigest()
29
+
30
+
31
+ def _build_payload(
32
+ *,
33
+ event: str,
34
+ ticket_id: str,
35
+ ticket_title: str,
36
+ board_id: str,
37
+ from_state: str | None,
38
+ to_state: str,
39
+ actor_type: str,
40
+ actor_id: str | None,
41
+ reason: str | None,
42
+ ) -> dict:
43
+ """Build the webhook JSON payload."""
44
+ return {
45
+ "event": event,
46
+ "timestamp": datetime.now(UTC).isoformat(),
47
+ "ticket": {
48
+ "id": ticket_id,
49
+ "title": ticket_title,
50
+ "board_id": board_id,
51
+ "from_state": from_state,
52
+ "to_state": to_state,
53
+ },
54
+ "actor": {
55
+ "type": actor_type,
56
+ "id": actor_id,
57
+ },
58
+ "reason": reason,
59
+ }
60
+
61
+
62
+ async def fire_webhooks(
63
+ webhooks: list[dict],
64
+ *,
65
+ ticket_id: str,
66
+ ticket_title: str,
67
+ board_id: str,
68
+ from_state: str | None,
69
+ to_state: str,
70
+ actor_type: str,
71
+ actor_id: str | None,
72
+ reason: str | None,
73
+ ) -> None:
74
+ """Send webhook notifications for a ticket transition.
75
+
76
+ Non-blocking best-effort: logs errors but never raises.
77
+ """
78
+ if not webhooks:
79
+ return
80
+
81
+ event = "ticket.transitioned"
82
+ payload = _build_payload(
83
+ event=event,
84
+ ticket_id=ticket_id,
85
+ ticket_title=ticket_title,
86
+ board_id=board_id,
87
+ from_state=from_state,
88
+ to_state=to_state,
89
+ actor_type=actor_type,
90
+ actor_id=actor_id,
91
+ reason=reason,
92
+ )
93
+ payload_bytes = json.dumps(payload, separators=(",", ":")).encode()
94
+
95
+ async with httpx.AsyncClient(timeout=WEBHOOK_TIMEOUT) as client:
96
+ for wh in webhooks:
97
+ url = wh.get("url")
98
+ if not url:
99
+ continue
100
+
101
+ # Check event filter
102
+ events = wh.get("events", ["*"])
103
+ if "*" not in events and event not in events:
104
+ continue
105
+
106
+ headers = {"Content-Type": "application/json"}
107
+ secret = wh.get("secret")
108
+ if secret:
109
+ headers["X-Webhook-Signature"] = (
110
+ f"sha256={_sign_payload(payload_bytes, secret)}"
111
+ )
112
+
113
+ try:
114
+ resp = await client.post(url, content=payload_bytes, headers=headers)
115
+ logger.info(
116
+ "Webhook delivered: url=%s status=%d ticket=%s %s->%s",
117
+ url,
118
+ resp.status_code,
119
+ ticket_id,
120
+ from_state,
121
+ to_state,
122
+ )
123
+ except Exception:
124
+ logger.warning(
125
+ "Webhook failed: url=%s ticket=%s", url, ticket_id, exc_info=True
126
+ )
@@ -0,0 +1,465 @@
1
+ """Service layer for Workspace operations with git worktree management."""
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+
9
+ from sqlalchemy import select
10
+ from sqlalchemy.orm import Session
11
+
12
+ from app.data_dir import get_logs_dir, get_worktree_dir, get_worktrees_root
13
+ from app.exceptions import (
14
+ BranchNotFoundError,
15
+ NotAGitRepositoryError,
16
+ ResourceNotFoundError,
17
+ WorktreeCreationError,
18
+ )
19
+ from app.models.board import Board
20
+ from app.models.ticket import Ticket
21
+ from app.models.workspace import Workspace
22
+
23
+ # Default workspace root (parent of backend directory)
24
+ DEFAULT_REPO_PATH = Path(__file__).parent.parent.parent.parent
25
+
26
+
27
+ class WorkspaceService:
28
+ """Service class for Workspace business logic and git worktree management."""
29
+
30
+ def __init__(self, db: Session):
31
+ """Initialize with a database session (sync or async compatible)."""
32
+ self.db = db
33
+
34
+ @staticmethod
35
+ def get_repo_path() -> Path:
36
+ """
37
+ Get the git repository path from environment or default.
38
+
39
+ Returns:
40
+ Path to the git repository root.
41
+ """
42
+ repo_path = os.getenv("GIT_REPO_PATH")
43
+ if repo_path:
44
+ return Path(repo_path)
45
+ return DEFAULT_REPO_PATH
46
+
47
+ @staticmethod
48
+ def get_base_branch() -> str:
49
+ """
50
+ Get the base branch name from environment or default.
51
+
52
+ Returns:
53
+ The base branch name (defaults to 'main').
54
+ """
55
+ return os.getenv("BASE_BRANCH", "main")
56
+
57
+ @classmethod
58
+ def ensure_repo_is_git(cls) -> Path:
59
+ """
60
+ Validate that the configured repo path is a git repository.
61
+
62
+ Returns:
63
+ The validated repo path.
64
+
65
+ Raises:
66
+ NotAGitRepositoryError: If the path is not a git repository.
67
+ """
68
+ repo_path = cls.get_repo_path()
69
+ git_dir = repo_path / ".git"
70
+
71
+ if not git_dir.exists():
72
+ raise NotAGitRepositoryError(str(repo_path))
73
+
74
+ return repo_path
75
+
76
+ @classmethod
77
+ def _run_git_command(
78
+ cls,
79
+ args: list[str],
80
+ cwd: Path | None = None,
81
+ check: bool = True,
82
+ ) -> subprocess.CompletedProcess[str]:
83
+ """
84
+ Run a git command using subprocess.
85
+
86
+ Args:
87
+ args: Git command arguments (without 'git' prefix).
88
+ cwd: Working directory for the command.
89
+ check: Whether to raise on non-zero exit code.
90
+
91
+ Returns:
92
+ CompletedProcess with stdout/stderr.
93
+
94
+ Raises:
95
+ WorktreeCreationError: If the command fails and check=True.
96
+ """
97
+ cmd = ["git"] + args
98
+ cwd = cwd or cls.get_repo_path()
99
+
100
+ try:
101
+ result = subprocess.run(
102
+ cmd,
103
+ cwd=cwd,
104
+ capture_output=True,
105
+ text=True,
106
+ check=check,
107
+ )
108
+ return result
109
+ except subprocess.CalledProcessError as e:
110
+ raise WorktreeCreationError(
111
+ f"Git command failed: {' '.join(cmd)}",
112
+ git_error=e.stderr.strip() if e.stderr else str(e),
113
+ )
114
+
115
+ @classmethod
116
+ def _validate_base_branch(cls, repo_path: Path) -> str:
117
+ """
118
+ Validate that the base branch exists, falling back to 'master' if needed.
119
+
120
+ Args:
121
+ repo_path: Path to the git repository.
122
+
123
+ Returns:
124
+ The validated base branch name.
125
+
126
+ Raises:
127
+ BranchNotFoundError: If neither main nor master branch exists.
128
+ """
129
+ base_branch = cls.get_base_branch()
130
+
131
+ # Check if the base branch exists
132
+ result = cls._run_git_command(
133
+ ["rev-parse", "--verify", f"refs/heads/{base_branch}"],
134
+ cwd=repo_path,
135
+ check=False,
136
+ )
137
+
138
+ if result.returncode == 0:
139
+ return base_branch
140
+
141
+ # If configured branch doesn't exist, try fallback to 'master'
142
+ if base_branch != "master":
143
+ result = cls._run_git_command(
144
+ ["rev-parse", "--verify", "refs/heads/master"],
145
+ cwd=repo_path,
146
+ check=False,
147
+ )
148
+ if result.returncode == 0:
149
+ return "master"
150
+
151
+ raise BranchNotFoundError(base_branch)
152
+
153
+ @classmethod
154
+ def _get_worktree_dir(cls, ticket_id: str, board_id: str | None = None) -> Path:
155
+ """
156
+ Get the worktree directory path for a ticket.
157
+
158
+ Uses central data dir: ~/.draft/worktrees/{board_id}/{ticket_id}/
159
+
160
+ Args:
161
+ ticket_id: The ticket UUID.
162
+ board_id: The board UUID (used for directory grouping).
163
+
164
+ Returns:
165
+ Path to the worktree directory.
166
+ """
167
+ return get_worktree_dir(board_id or "default", ticket_id)
168
+
169
+ @classmethod
170
+ def _get_branch_name(cls, goal_id: str, ticket_id: str) -> str:
171
+ """
172
+ Generate the branch name for a ticket.
173
+
174
+ Args:
175
+ goal_id: The goal UUID.
176
+ ticket_id: The ticket UUID.
177
+
178
+ Returns:
179
+ Branch name in format: goal/{goal_id}/ticket/{ticket_id}
180
+ """
181
+ return f"goal/{goal_id}/ticket/{ticket_id}"
182
+
183
+ def get_workspace_by_ticket_id(self, ticket_id: str) -> Workspace | None:
184
+ """
185
+ Get workspace for a ticket.
186
+
187
+ Args:
188
+ ticket_id: The ticket UUID.
189
+
190
+ Returns:
191
+ Workspace if exists, None otherwise.
192
+ """
193
+ result = self.db.execute(
194
+ select(Workspace).where(Workspace.ticket_id == ticket_id)
195
+ )
196
+ return result.scalar_one_or_none()
197
+
198
+ def get_worktree_path(self, ticket_id: str) -> Path | None:
199
+ """
200
+ Get the worktree path for a ticket from the database.
201
+
202
+ Args:
203
+ ticket_id: The ticket UUID.
204
+
205
+ Returns:
206
+ Path to the worktree directory, or None if not found or cleaned up.
207
+ """
208
+ workspace = self.get_workspace_by_ticket_id(ticket_id)
209
+ if workspace and workspace.is_active:
210
+ return Path(workspace.worktree_path)
211
+ return None
212
+
213
+ def create_worktree(self, ticket_id: str, goal_id: str) -> Workspace:
214
+ """
215
+ Create a git worktree for a ticket.
216
+
217
+ This method:
218
+ 1. Validates the repository is a git repo
219
+ 2. Validates the base branch exists
220
+ 3. Creates a new branch based on the base branch
221
+ 4. Creates a worktree at .draft/worktrees/{ticket_id}/
222
+ 5. Records the workspace in the database
223
+
224
+ Args:
225
+ ticket_id: The ticket UUID.
226
+ goal_id: The goal UUID (for branch naming).
227
+
228
+ Returns:
229
+ The created Workspace instance.
230
+
231
+ Raises:
232
+ NotAGitRepositoryError: If not a git repository.
233
+ BranchNotFoundError: If base branch doesn't exist.
234
+ WorktreeCreationError: If worktree creation fails.
235
+ ResourceNotFoundError: If the ticket doesn't exist.
236
+ """
237
+ # Check if workspace already exists
238
+ existing = self.get_workspace_by_ticket_id(ticket_id)
239
+ if existing and existing.is_active:
240
+ return existing
241
+
242
+ # Verify ticket exists
243
+ result = self.db.execute(select(Ticket).where(Ticket.id == ticket_id))
244
+ ticket = result.scalar_one_or_none()
245
+ if ticket is None:
246
+ raise ResourceNotFoundError("Ticket", ticket_id)
247
+
248
+ # Use board's repo_root if available, otherwise fall back to env/default
249
+ board_repo_root = None
250
+ if ticket.board_id:
251
+ board_result = self.db.execute(
252
+ select(Board).where(Board.id == ticket.board_id)
253
+ )
254
+ board = board_result.scalar_one_or_none()
255
+ if board and board.repo_root:
256
+ board_repo_root = Path(board.repo_root)
257
+
258
+ # Validate git repo
259
+ if board_repo_root:
260
+ git_dir = board_repo_root / ".git"
261
+ if not git_dir.exists():
262
+ raise NotAGitRepositoryError(str(board_repo_root))
263
+ repo_path = board_repo_root
264
+ else:
265
+ repo_path = self.ensure_repo_is_git()
266
+
267
+ # Validate base branch
268
+ base_branch = self._validate_base_branch(repo_path)
269
+
270
+ # Generate paths and names
271
+ worktree_dir = self._get_worktree_dir(ticket_id, board_id=ticket.board_id)
272
+ branch_name = self._get_branch_name(goal_id, ticket_id)
273
+
274
+ # Create parent directories
275
+ worktree_dir.parent.mkdir(parents=True, exist_ok=True)
276
+
277
+ # Remove existing worktree directory if it exists (from a previous failed attempt)
278
+ if worktree_dir.exists():
279
+ # Security: reject symlinks to prevent directory traversal attacks
280
+ if worktree_dir.is_symlink():
281
+ raise WorktreeCreationError(
282
+ f"Worktree path is a symlink (potential security issue): {worktree_dir}",
283
+ git_error="symlink_detected",
284
+ )
285
+ # Security: ensure resolved path stays within the central data dir
286
+ resolved = worktree_dir.resolve()
287
+ worktrees_root = get_worktrees_root().resolve()
288
+ if not str(resolved).startswith(str(worktrees_root) + os.sep):
289
+ raise WorktreeCreationError(
290
+ f"Worktree path escapes data dir boundary: {resolved}",
291
+ git_error="path_traversal_detected",
292
+ )
293
+ shutil.rmtree(worktree_dir)
294
+
295
+ # Check if branch already exists (e.g., from previous execution before cleanup)
296
+ branch_exists_result = self._run_git_command(
297
+ ["rev-parse", "--verify", f"refs/heads/{branch_name}"],
298
+ cwd=repo_path,
299
+ check=False,
300
+ )
301
+ branch_exists = branch_exists_result.returncode == 0
302
+
303
+ if branch_exists:
304
+ # Branch exists - create worktree using existing branch
305
+ # First, make sure the branch isn't checked out elsewhere
306
+ self._run_git_command(
307
+ ["worktree", "prune"],
308
+ cwd=repo_path,
309
+ check=False,
310
+ )
311
+ # Create worktree with existing branch
312
+ self._run_git_command(
313
+ ["worktree", "add", str(worktree_dir), branch_name],
314
+ cwd=repo_path,
315
+ )
316
+ else:
317
+ # Create the worktree with a new branch
318
+ self._run_git_command(
319
+ ["worktree", "add", "-b", branch_name, str(worktree_dir), base_branch],
320
+ cwd=repo_path,
321
+ )
322
+
323
+ # Create or update workspace record
324
+ if existing:
325
+ # Reactivate existing workspace
326
+ existing.worktree_path = str(worktree_dir)
327
+ existing.branch_name = branch_name
328
+ existing.cleaned_up_at = None
329
+ self.db.flush()
330
+ return existing
331
+
332
+ # Create new workspace record
333
+ workspace = Workspace(
334
+ ticket_id=ticket_id,
335
+ board_id=ticket.board_id,
336
+ worktree_path=str(worktree_dir),
337
+ branch_name=branch_name,
338
+ )
339
+ self.db.add(workspace)
340
+ self.db.flush()
341
+
342
+ return workspace
343
+
344
+ def ensure_workspace(self, ticket_id: str, goal_id: str) -> Workspace:
345
+ """
346
+ Ensure a workspace exists for a ticket, creating one if necessary.
347
+
348
+ Args:
349
+ ticket_id: The ticket UUID.
350
+ goal_id: The goal UUID.
351
+
352
+ Returns:
353
+ The existing or newly created Workspace.
354
+ """
355
+ workspace = self.get_workspace_by_ticket_id(ticket_id)
356
+ if workspace and workspace.is_active:
357
+ # Verify the worktree directory still exists
358
+ worktree_path = Path(workspace.worktree_path)
359
+ if worktree_path.exists():
360
+ # Verify the worktree belongs to the board's repo (not a stale worktree
361
+ # from a different repo, e.g. after GIT_REPO_PATH was changed).
362
+ if self._worktree_matches_board_repo(worktree_path, workspace.board_id):
363
+ return workspace
364
+ # Wrong repo — force cleanup and recreate
365
+ import logging
366
+
367
+ logging.getLogger(__name__).warning(
368
+ f"Worktree {worktree_path} belongs to wrong repo; recreating."
369
+ )
370
+ workspace.cleaned_up_at = datetime.now(UTC)
371
+ self.db.flush()
372
+ else:
373
+ # Worktree was deleted externally, recreate it
374
+ workspace.cleaned_up_at = datetime.now(UTC)
375
+ self.db.flush()
376
+
377
+ return self.create_worktree(ticket_id, goal_id)
378
+
379
+ def _worktree_matches_board_repo(
380
+ self, worktree_path: Path, board_id: str | None
381
+ ) -> bool:
382
+ """Check that an existing worktree belongs to the board's repo_root."""
383
+ if not board_id:
384
+ return True # No board — can't verify, assume OK
385
+ board_result = self.db.execute(select(Board).where(Board.id == board_id))
386
+ board = board_result.scalar_one_or_none()
387
+ if not board or not board.repo_root:
388
+ return True # No repo_root configured — assume OK
389
+ try:
390
+ result = subprocess.run(
391
+ ["git", "rev-parse", "--git-common-dir"],
392
+ cwd=worktree_path,
393
+ capture_output=True,
394
+ text=True,
395
+ timeout=10,
396
+ )
397
+ if result.returncode != 0:
398
+ return False
399
+ common_dir = Path(result.stdout.strip()).resolve()
400
+ board_git_dir = (Path(board.repo_root) / ".git").resolve()
401
+ return str(common_dir).startswith(str(board_git_dir))
402
+ except Exception:
403
+ return True # Can't verify, assume OK
404
+
405
+ def cleanup_worktree(self, ticket_id: str) -> bool:
406
+ """
407
+ Remove a worktree and mark it as cleaned up.
408
+
409
+ Args:
410
+ ticket_id: The ticket UUID.
411
+
412
+ Returns:
413
+ True if cleanup was performed, False if no active workspace found.
414
+ """
415
+ workspace = self.get_workspace_by_ticket_id(ticket_id)
416
+ if not workspace or not workspace.is_active:
417
+ return False
418
+
419
+ # Use board's repo_root if available
420
+ repo_path = self.get_repo_path()
421
+ if workspace.board_id:
422
+ board_result = self.db.execute(
423
+ select(Board).where(Board.id == workspace.board_id)
424
+ )
425
+ board = board_result.scalar_one_or_none()
426
+ if board and board.repo_root:
427
+ repo_path = Path(board.repo_root)
428
+ worktree_dir = Path(workspace.worktree_path)
429
+
430
+ # Remove the worktree using git
431
+ if worktree_dir.exists():
432
+ self._run_git_command(
433
+ ["worktree", "remove", "--force", str(worktree_dir)],
434
+ cwd=repo_path,
435
+ check=False, # Don't fail if already removed
436
+ )
437
+
438
+ # Delete the branch
439
+ self._run_git_command(
440
+ ["branch", "-D", workspace.branch_name],
441
+ cwd=repo_path,
442
+ check=False, # Don't fail if branch doesn't exist
443
+ )
444
+
445
+ # Mark as cleaned up
446
+ workspace.cleaned_up_at = datetime.now(UTC)
447
+ self.db.flush()
448
+
449
+ return True
450
+
451
+ def get_logs_dir(self, ticket_id: str) -> Path | None:
452
+ """
453
+ Get the central logs directory.
454
+
455
+ Args:
456
+ ticket_id: The ticket UUID (unused, kept for API compat).
457
+
458
+ Returns:
459
+ Path to the central logs directory, or None if no active workspace.
460
+ """
461
+ worktree_path = self.get_worktree_path(ticket_id)
462
+ if worktree_path is None:
463
+ return None
464
+
465
+ return get_logs_dir()
@@ -0,0 +1,92 @@
1
+ """Service for browsing worktree file trees."""
2
+
3
+ from pathlib import Path
4
+
5
+ # Directories to always skip
6
+ SKIP_DIRS = {
7
+ ".git",
8
+ ".draft",
9
+ "__pycache__",
10
+ "node_modules",
11
+ ".venv",
12
+ "venv",
13
+ ".ruff_cache",
14
+ ".pytest_cache",
15
+ ".mypy_cache",
16
+ "dist",
17
+ "build",
18
+ ".next",
19
+ ".nuxt",
20
+ }
21
+
22
+ # Max depth to prevent extremely deep traversal
23
+ MAX_DEPTH = 8
24
+ # Max total entries to return
25
+ MAX_ENTRIES = 500
26
+
27
+
28
+ def build_file_tree(
29
+ root_path: str,
30
+ max_depth: int = MAX_DEPTH,
31
+ max_entries: int = MAX_ENTRIES,
32
+ ) -> dict | None:
33
+ """Build a file tree dictionary from a worktree path.
34
+
35
+ Args:
36
+ root_path: Absolute path to the worktree root.
37
+ max_depth: Maximum directory depth to traverse.
38
+ max_entries: Maximum total entries to include.
39
+
40
+ Returns:
41
+ Dict with name, path, is_dir, children, size fields.
42
+ None if the path doesn't exist.
43
+ """
44
+ root = Path(root_path)
45
+ if not root.exists() or not root.is_dir():
46
+ return None
47
+
48
+ entry_count = [0] # Use list for mutation in nested function
49
+
50
+ def _build(path: Path, depth: int) -> dict | None:
51
+ if entry_count[0] >= max_entries:
52
+ return None
53
+
54
+ entry_count[0] += 1
55
+ rel_path = str(path.relative_to(root))
56
+ node = {
57
+ "name": path.name or root_path.split("/")[-1],
58
+ "path": rel_path if rel_path != "." else "",
59
+ "is_dir": path.is_dir(),
60
+ }
61
+
62
+ if path.is_dir():
63
+ if depth >= max_depth:
64
+ node["children"] = []
65
+ return node
66
+
67
+ children = []
68
+ try:
69
+ entries = sorted(
70
+ path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())
71
+ )
72
+ for entry in entries:
73
+ if entry.name in SKIP_DIRS:
74
+ continue
75
+ if entry.name.startswith(".") and entry.is_dir():
76
+ continue # Skip hidden directories
77
+ child = _build(entry, depth + 1)
78
+ if child:
79
+ children.append(child)
80
+ except PermissionError:
81
+ pass
82
+
83
+ node["children"] = children
84
+ else:
85
+ try:
86
+ node["size"] = path.stat().st_size
87
+ except OSError:
88
+ node["size"] = 0
89
+
90
+ return node
91
+
92
+ return _build(root, 0)