draft-board 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/backend/.env.example +9 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_patch.txt +195 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_stat.txt +6 -0
- package/app/backend/CURL_EXAMPLES.md +335 -0
- package/app/backend/ENV_SETUP.md +65 -0
- package/app/backend/alembic/env.py +71 -0
- package/app/backend/alembic/script.py.mako +28 -0
- package/app/backend/alembic/versions/001_initial_schema.py +104 -0
- package/app/backend/alembic/versions/002_add_jobs_table.py +52 -0
- package/app/backend/alembic/versions/003_add_workspace_table.py +48 -0
- package/app/backend/alembic/versions/004_add_evidence_table.py +56 -0
- package/app/backend/alembic/versions/005_add_verification_commands.py +32 -0
- package/app/backend/alembic/versions/006_add_planner_lock_table.py +39 -0
- package/app/backend/alembic/versions/007_add_revision_review_tables.py +126 -0
- package/app/backend/alembic/versions/008_add_revision_idempotency_and_traceability.py +52 -0
- package/app/backend/alembic/versions/009_add_job_health_fields.py +46 -0
- package/app/backend/alembic/versions/010_add_review_comment_line_content.py +36 -0
- package/app/backend/alembic/versions/011_add_analysis_cache.py +47 -0
- package/app/backend/alembic/versions/012_add_boards_table.py +102 -0
- package/app/backend/alembic/versions/013_add_ticket_blocking.py +45 -0
- package/app/backend/alembic/versions/014_add_agent_sessions.py +220 -0
- package/app/backend/alembic/versions/015_add_ticket_sort_order.py +33 -0
- package/app/backend/alembic/versions/03220f0b93ae_add_pr_fields_to_ticket.py +49 -0
- package/app/backend/alembic/versions/0c2d89fff3b1_seed_board_configs_from_yaml.py +206 -0
- package/app/backend/alembic/versions/3348e5cf54c1_add_merge_checklist_table.py +67 -0
- package/app/backend/alembic/versions/357c780ee445_add_goal_status.py +34 -0
- package/app/backend/alembic/versions/553340b7e26c_add_autonomy_fields_to_goal.py +65 -0
- package/app/backend/alembic/versions/774dc335c679_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/7b307e847cbd_merge_heads.py +23 -0
- package/app/backend/alembic/versions/82ecd978cc70_add_missing_indexes.py +48 -0
- package/app/backend/alembic/versions/8ef5054dc280_add_normalized_log_entries.py +173 -0
- package/app/backend/alembic/versions/8f3e2bd8ea3b_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/9d17f0698d3b_add_config_column_to_boards_table.py +30 -0
- package/app/backend/alembic/versions/add_agent_conversation_history.py +72 -0
- package/app/backend/alembic/versions/add_job_variant.py +34 -0
- package/app/backend/alembic/versions/add_performance_indexes.py +95 -0
- package/app/backend/alembic/versions/add_repos_and_board_repos.py +174 -0
- package/app/backend/alembic/versions/add_session_id_to_jobs.py +27 -0
- package/app/backend/alembic/versions/add_sqlite_backend_tables.py +104 -0
- package/app/backend/alembic/versions/b10fb0b62240_add_diff_content_to_revisions.py +34 -0
- package/app/backend/alembic.ini +89 -0
- package/app/backend/app/__init__.py +3 -0
- package/app/backend/app/data_dir.py +85 -0
- package/app/backend/app/database.py +70 -0
- package/app/backend/app/database_sync.py +64 -0
- package/app/backend/app/dependencies/__init__.py +5 -0
- package/app/backend/app/dependencies/auth.py +80 -0
- package/app/backend/app/dependencies.py +43 -0
- package/app/backend/app/exceptions.py +178 -0
- package/app/backend/app/executors/__init__.py +1 -0
- package/app/backend/app/executors/adapters/__init__.py +1 -0
- package/app/backend/app/executors/adapters/aider.py +152 -0
- package/app/backend/app/executors/adapters/amazon_q.py +103 -0
- package/app/backend/app/executors/adapters/amp.py +123 -0
- package/app/backend/app/executors/adapters/claude.py +177 -0
- package/app/backend/app/executors/adapters/cline.py +127 -0
- package/app/backend/app/executors/adapters/codex.py +167 -0
- package/app/backend/app/executors/adapters/copilot.py +202 -0
- package/app/backend/app/executors/adapters/cursor.py +87 -0
- package/app/backend/app/executors/adapters/droid.py +123 -0
- package/app/backend/app/executors/adapters/gemini.py +132 -0
- package/app/backend/app/executors/adapters/goose.py +131 -0
- package/app/backend/app/executors/adapters/opencode.py +123 -0
- package/app/backend/app/executors/adapters/qwen.py +123 -0
- package/app/backend/app/executors/plugins/__init__.py +1 -0
- package/app/backend/app/executors/registry.py +202 -0
- package/app/backend/app/executors/spec.py +226 -0
- package/app/backend/app/main.py +486 -0
- package/app/backend/app/middleware/__init__.py +13 -0
- package/app/backend/app/middleware/idempotency.py +426 -0
- package/app/backend/app/middleware/rate_limit.py +312 -0
- package/app/backend/app/middleware/security_headers.py +43 -0
- package/app/backend/app/middleware/timeout.py +37 -0
- package/app/backend/app/models/__init__.py +56 -0
- package/app/backend/app/models/agent_conversation_history.py +56 -0
- package/app/backend/app/models/agent_session.py +127 -0
- package/app/backend/app/models/analysis_cache.py +49 -0
- package/app/backend/app/models/base.py +9 -0
- package/app/backend/app/models/board.py +79 -0
- package/app/backend/app/models/board_repo.py +68 -0
- package/app/backend/app/models/cost_budget.py +42 -0
- package/app/backend/app/models/enums.py +40 -0
- package/app/backend/app/models/evidence.py +132 -0
- package/app/backend/app/models/goal.py +102 -0
- package/app/backend/app/models/idempotency_entry.py +30 -0
- package/app/backend/app/models/job.py +163 -0
- package/app/backend/app/models/job_queue.py +39 -0
- package/app/backend/app/models/kv_store.py +28 -0
- package/app/backend/app/models/merge_checklist.py +87 -0
- package/app/backend/app/models/normalized_log.py +100 -0
- package/app/backend/app/models/planner_lock.py +43 -0
- package/app/backend/app/models/rate_limit_entry.py +25 -0
- package/app/backend/app/models/repo.py +66 -0
- package/app/backend/app/models/review_comment.py +91 -0
- package/app/backend/app/models/review_summary.py +69 -0
- package/app/backend/app/models/revision.py +130 -0
- package/app/backend/app/models/ticket.py +223 -0
- package/app/backend/app/models/ticket_event.py +83 -0
- package/app/backend/app/models/user.py +47 -0
- package/app/backend/app/models/workspace.py +71 -0
- package/app/backend/app/redis_client.py +119 -0
- package/app/backend/app/routers/__init__.py +29 -0
- package/app/backend/app/routers/agents.py +296 -0
- package/app/backend/app/routers/auth.py +94 -0
- package/app/backend/app/routers/board.py +885 -0
- package/app/backend/app/routers/dashboard.py +351 -0
- package/app/backend/app/routers/debug.py +528 -0
- package/app/backend/app/routers/evidence.py +96 -0
- package/app/backend/app/routers/executors.py +324 -0
- package/app/backend/app/routers/goals.py +574 -0
- package/app/backend/app/routers/jobs.py +448 -0
- package/app/backend/app/routers/maintenance.py +172 -0
- package/app/backend/app/routers/merge.py +360 -0
- package/app/backend/app/routers/planner.py +537 -0
- package/app/backend/app/routers/pull_requests.py +382 -0
- package/app/backend/app/routers/repos.py +263 -0
- package/app/backend/app/routers/revisions.py +939 -0
- package/app/backend/app/routers/settings.py +267 -0
- package/app/backend/app/routers/tickets.py +2003 -0
- package/app/backend/app/routers/webhooks.py +143 -0
- package/app/backend/app/routers/websocket.py +249 -0
- package/app/backend/app/schemas/__init__.py +109 -0
- package/app/backend/app/schemas/board.py +87 -0
- package/app/backend/app/schemas/common.py +33 -0
- package/app/backend/app/schemas/evidence.py +87 -0
- package/app/backend/app/schemas/goal.py +90 -0
- package/app/backend/app/schemas/job.py +97 -0
- package/app/backend/app/schemas/merge.py +139 -0
- package/app/backend/app/schemas/planner.py +500 -0
- package/app/backend/app/schemas/repo.py +187 -0
- package/app/backend/app/schemas/review.py +137 -0
- package/app/backend/app/schemas/revision.py +114 -0
- package/app/backend/app/schemas/ticket.py +238 -0
- package/app/backend/app/schemas/ticket_event.py +72 -0
- package/app/backend/app/schemas/workspace.py +19 -0
- package/app/backend/app/services/__init__.py +31 -0
- package/app/backend/app/services/agent_memory_service.py +223 -0
- package/app/backend/app/services/agent_registry.py +346 -0
- package/app/backend/app/services/agent_session_manager.py +318 -0
- package/app/backend/app/services/agent_session_service.py +219 -0
- package/app/backend/app/services/agent_tools.py +379 -0
- package/app/backend/app/services/auth_service.py +98 -0
- package/app/backend/app/services/autonomy_service.py +380 -0
- package/app/backend/app/services/board_repo_service.py +201 -0
- package/app/backend/app/services/board_service.py +326 -0
- package/app/backend/app/services/cleanup_service.py +1085 -0
- package/app/backend/app/services/config_service.py +908 -0
- package/app/backend/app/services/context_gatherer.py +557 -0
- package/app/backend/app/services/cost_tracking_service.py +293 -0
- package/app/backend/app/services/cursor_log_normalizer.py +536 -0
- package/app/backend/app/services/delivery_pipeline.py +440 -0
- package/app/backend/app/services/executor_service.py +634 -0
- package/app/backend/app/services/git_host/__init__.py +11 -0
- package/app/backend/app/services/git_host/factory.py +87 -0
- package/app/backend/app/services/git_host/github.py +270 -0
- package/app/backend/app/services/git_host/gitlab.py +194 -0
- package/app/backend/app/services/git_host/protocol.py +75 -0
- package/app/backend/app/services/git_merge_simple.py +346 -0
- package/app/backend/app/services/git_ops.py +384 -0
- package/app/backend/app/services/github_service.py +233 -0
- package/app/backend/app/services/goal_service.py +113 -0
- package/app/backend/app/services/job_service.py +423 -0
- package/app/backend/app/services/job_watchdog_service.py +424 -0
- package/app/backend/app/services/langchain_adapter.py +122 -0
- package/app/backend/app/services/llm_provider_clients.py +351 -0
- package/app/backend/app/services/llm_service.py +285 -0
- package/app/backend/app/services/log_normalizer.py +342 -0
- package/app/backend/app/services/log_stream_service.py +276 -0
- package/app/backend/app/services/merge_checklist_service.py +264 -0
- package/app/backend/app/services/merge_service.py +784 -0
- package/app/backend/app/services/orchestrator_log.py +84 -0
- package/app/backend/app/services/planner_service.py +1662 -0
- package/app/backend/app/services/planner_tick_sync.py +1040 -0
- package/app/backend/app/services/queued_message_service.py +156 -0
- package/app/backend/app/services/reliability_wrapper.py +389 -0
- package/app/backend/app/services/repo_discovery_service.py +318 -0
- package/app/backend/app/services/review_service.py +334 -0
- package/app/backend/app/services/revision_service.py +389 -0
- package/app/backend/app/services/safe_autopilot.py +510 -0
- package/app/backend/app/services/sqlite_worker.py +372 -0
- package/app/backend/app/services/task_dispatch.py +135 -0
- package/app/backend/app/services/ticket_generation_service.py +1781 -0
- package/app/backend/app/services/ticket_service.py +486 -0
- package/app/backend/app/services/udar_planner_service.py +1007 -0
- package/app/backend/app/services/webhook_service.py +126 -0
- package/app/backend/app/services/workspace_service.py +465 -0
- package/app/backend/app/services/worktree_file_service.py +92 -0
- package/app/backend/app/services/worktree_validator.py +213 -0
- package/app/backend/app/sqlite_kv.py +278 -0
- package/app/backend/app/state_machine.py +128 -0
- package/app/backend/app/templates/__init__.py +5 -0
- package/app/backend/app/templates/registry.py +243 -0
- package/app/backend/app/utils/__init__.py +5 -0
- package/app/backend/app/utils/artifact_reader.py +87 -0
- package/app/backend/app/utils/circuit_breaker.py +229 -0
- package/app/backend/app/utils/db_retry.py +136 -0
- package/app/backend/app/utils/ignored_fields.py +123 -0
- package/app/backend/app/utils/validators.py +54 -0
- package/app/backend/app/websocket/__init__.py +5 -0
- package/app/backend/app/websocket/manager.py +179 -0
- package/app/backend/app/websocket/state_tracker.py +113 -0
- package/app/backend/app/worker.py +3190 -0
- package/app/backend/calculator_tickets.json +40 -0
- package/app/backend/canary_tests.sh +591 -0
- package/app/backend/celerybeat-schedule +0 -0
- package/app/backend/celerybeat-schedule-shm +0 -0
- package/app/backend/celerybeat-schedule-wal +0 -0
- package/app/backend/logs/.gitkeep +3 -0
- package/app/backend/multiplication_division_implementation_tickets.json +55 -0
- package/app/backend/multiplication_division_tickets.json +42 -0
- package/app/backend/pyproject.toml +45 -0
- package/app/backend/requirements-dev.txt +8 -0
- package/app/backend/requirements.txt +20 -0
- package/app/backend/run.sh +30 -0
- package/app/backend/run_with_logs.sh +10 -0
- package/app/backend/scientific_calculator_tickets.json +40 -0
- package/app/backend/scripts/extract_openapi.py +21 -0
- package/app/backend/scripts/seed_demo.py +187 -0
- package/app/backend/setup_demo_review.py +302 -0
- package/app/backend/test_actual_parse.py +41 -0
- package/app/backend/test_agent_streaming.py +61 -0
- package/app/backend/test_parse.py +51 -0
- package/app/backend/test_streaming.py +51 -0
- package/app/backend/test_subprocess_streaming.py +50 -0
- package/app/backend/tests/__init__.py +1 -0
- package/app/backend/tests/conftest.py +46 -0
- package/app/backend/tests/test_auth.py +341 -0
- package/app/backend/tests/test_autonomy_service.py +391 -0
- package/app/backend/tests/test_cleanup_service_safety.py +417 -0
- package/app/backend/tests/test_middleware.py +279 -0
- package/app/backend/tests/test_planner_providers.py +290 -0
- package/app/backend/tests/test_planner_unblock.py +183 -0
- package/app/backend/tests/test_revision_invariants.py +618 -0
- package/app/backend/tests/test_sqlite_kv.py +290 -0
- package/app/backend/tests/test_sqlite_worker.py +353 -0
- package/app/backend/tests/test_task_dispatch.py +100 -0
- package/app/backend/tests/test_ticket_validation.py +304 -0
- package/app/backend/tests/test_udar_agent.py +693 -0
- package/app/backend/tests/test_webhook_service.py +184 -0
- package/app/backend/tickets_output.json +59 -0
- package/app/backend/user_management_tickets.json +50 -0
- package/app/backend/uvicorn.log +0 -0
- package/app/draft.yaml +313 -0
- package/app/frontend/dist/assets/index-LcjCczu5.js +155 -0
- package/app/frontend/dist/assets/index-_FP_279e.css +1 -0
- package/app/frontend/dist/index.html +14 -0
- package/app/frontend/dist/vite.svg +1 -0
- package/app/frontend/package.json +101 -0
- package/bin/cli.js +527 -0
- package/package.json +37 -0
|
@@ -0,0 +1,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)
|