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,326 @@
|
|
|
1
|
+
"""Service for Board operations and authorization.
|
|
2
|
+
|
|
3
|
+
CRITICAL: Board is the primary permission boundary.
|
|
4
|
+
- All goals, tickets, jobs, workspaces belong to a board
|
|
5
|
+
- All filesystem operations use board.repo_root (NEVER global config)
|
|
6
|
+
- All mutating endpoints must validate board_id ownership
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from sqlalchemy import select
|
|
13
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
14
|
+
|
|
15
|
+
from app.models import Board, Goal, Job, Ticket, Workspace
|
|
16
|
+
from app.schemas.board import BoardCreate, BoardUpdate
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BoardService:
|
|
20
|
+
"""Service for managing boards and enforcing board boundaries."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, db: AsyncSession):
|
|
23
|
+
self.db = db
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def get_default_board_config() -> dict:
|
|
27
|
+
"""Get full default configuration for new boards.
|
|
28
|
+
|
|
29
|
+
Returns a complete DraftConfig as a dict so that the DB
|
|
30
|
+
is the single source of truth (no YAML needed at runtime).
|
|
31
|
+
|
|
32
|
+
Key defaults:
|
|
33
|
+
- executor_model: "sonnet-4.5" (fast and cost-effective)
|
|
34
|
+
- timeout: 300 (5 minutes, reasonable for most tasks)
|
|
35
|
+
"""
|
|
36
|
+
from app.services.config_service import DraftConfig
|
|
37
|
+
|
|
38
|
+
config = DraftConfig()
|
|
39
|
+
full = config.to_dict()
|
|
40
|
+
# Override with our preferred defaults
|
|
41
|
+
full["execute_config"]["executor_model"] = "sonnet-4.5"
|
|
42
|
+
full["execute_config"]["timeout"] = 300
|
|
43
|
+
return full
|
|
44
|
+
|
|
45
|
+
async def create_board(
|
|
46
|
+
self, data: BoardCreate, owner_id: str | None = None
|
|
47
|
+
) -> Board:
|
|
48
|
+
"""Create a new board with sensible default configuration.
|
|
49
|
+
|
|
50
|
+
CRITICAL: repo_root becomes the authoritative path for all
|
|
51
|
+
filesystem operations on this board.
|
|
52
|
+
|
|
53
|
+
The board is initialized with default config to prevent falling back
|
|
54
|
+
to YAML config which may have non-optimal defaults (e.g., "auto" model).
|
|
55
|
+
|
|
56
|
+
If template_id is provided, applies template configuration and creates
|
|
57
|
+
starter goals (unless create_starter_goals=False).
|
|
58
|
+
"""
|
|
59
|
+
# Validate repo_root exists and is a git repo
|
|
60
|
+
repo_path = Path(data.repo_root).resolve()
|
|
61
|
+
if not repo_path.exists():
|
|
62
|
+
raise ValueError(f"repo_root does not exist: {data.repo_root}")
|
|
63
|
+
if not repo_path.is_dir():
|
|
64
|
+
raise ValueError(f"repo_root is not a directory: {data.repo_root}")
|
|
65
|
+
if not (repo_path / ".git").exists():
|
|
66
|
+
raise ValueError(f"repo_root is not a git repository: {data.repo_root}")
|
|
67
|
+
|
|
68
|
+
# Apply template config if template_id provided
|
|
69
|
+
board_config = self.get_default_board_config()
|
|
70
|
+
if data.template_id:
|
|
71
|
+
from app.templates import get_template
|
|
72
|
+
|
|
73
|
+
template = get_template(data.template_id)
|
|
74
|
+
if not template:
|
|
75
|
+
raise ValueError(f"Invalid template_id: {data.template_id}")
|
|
76
|
+
|
|
77
|
+
# Merge template config with defaults (template takes precedence)
|
|
78
|
+
if template.get("config"):
|
|
79
|
+
from app.services.config_service import deep_merge_dicts
|
|
80
|
+
|
|
81
|
+
board_config = deep_merge_dicts(board_config, template["config"])
|
|
82
|
+
|
|
83
|
+
board = Board(
|
|
84
|
+
id=str(uuid.uuid4()),
|
|
85
|
+
name=data.name,
|
|
86
|
+
description=data.description,
|
|
87
|
+
repo_root=str(repo_path), # Store resolved absolute path
|
|
88
|
+
default_branch=data.default_branch,
|
|
89
|
+
config=board_config,
|
|
90
|
+
owner_id=owner_id,
|
|
91
|
+
)
|
|
92
|
+
self.db.add(board)
|
|
93
|
+
await self.db.commit()
|
|
94
|
+
await self.db.refresh(board)
|
|
95
|
+
|
|
96
|
+
# Create starter goals if template provided and requested
|
|
97
|
+
if data.template_id and data.create_starter_goals:
|
|
98
|
+
from app.templates import get_template
|
|
99
|
+
|
|
100
|
+
template = get_template(data.template_id)
|
|
101
|
+
if template and template.get("starter_goals"):
|
|
102
|
+
for goal_data in template["starter_goals"]:
|
|
103
|
+
goal = Goal(
|
|
104
|
+
id=str(uuid.uuid4()),
|
|
105
|
+
board_id=board.id,
|
|
106
|
+
title=goal_data["title"],
|
|
107
|
+
description=goal_data["description"],
|
|
108
|
+
)
|
|
109
|
+
self.db.add(goal)
|
|
110
|
+
|
|
111
|
+
await self.db.commit()
|
|
112
|
+
|
|
113
|
+
return board
|
|
114
|
+
|
|
115
|
+
async def get_board_by_id(self, board_id: str) -> Board:
|
|
116
|
+
"""Get a board by its ID."""
|
|
117
|
+
result = await self.db.execute(select(Board).where(Board.id == board_id))
|
|
118
|
+
board = result.scalar_one_or_none()
|
|
119
|
+
if not board:
|
|
120
|
+
raise ValueError(f"Board not found: {board_id}")
|
|
121
|
+
return board
|
|
122
|
+
|
|
123
|
+
async def get_boards(self, owner_id: str | None = None) -> list[Board]:
|
|
124
|
+
"""Get all boards, optionally filtered by owner.
|
|
125
|
+
|
|
126
|
+
When owner_id is provided, returns only boards owned by that user.
|
|
127
|
+
When owner_id is None, returns all boards (backward compatible).
|
|
128
|
+
"""
|
|
129
|
+
query = select(Board)
|
|
130
|
+
if owner_id is not None:
|
|
131
|
+
query = query.where(Board.owner_id == owner_id)
|
|
132
|
+
result = await self.db.execute(query)
|
|
133
|
+
return list(result.scalars().all())
|
|
134
|
+
|
|
135
|
+
async def update_board(self, board_id: str, data: BoardUpdate) -> Board:
|
|
136
|
+
"""Update a board."""
|
|
137
|
+
board = await self.get_board_by_id(board_id)
|
|
138
|
+
|
|
139
|
+
update_data = data.model_dump(exclude_unset=True)
|
|
140
|
+
for field, value in update_data.items():
|
|
141
|
+
setattr(board, field, value)
|
|
142
|
+
|
|
143
|
+
await self.db.commit()
|
|
144
|
+
await self.db.refresh(board)
|
|
145
|
+
return board
|
|
146
|
+
|
|
147
|
+
async def delete_board(self, board_id: str) -> None:
|
|
148
|
+
"""Delete a board and all its children (cascades)."""
|
|
149
|
+
board = await self.get_board_by_id(board_id)
|
|
150
|
+
await self.db.delete(board)
|
|
151
|
+
await self.db.commit()
|
|
152
|
+
|
|
153
|
+
async def initialize_board_config(self, board_id: str) -> Board:
|
|
154
|
+
"""Initialize config for a board that has config=null.
|
|
155
|
+
|
|
156
|
+
This is useful for migrating existing boards that were created
|
|
157
|
+
before auto-initialization was implemented.
|
|
158
|
+
|
|
159
|
+
If board already has config, this is a no-op.
|
|
160
|
+
"""
|
|
161
|
+
board = await self.get_board_by_id(board_id)
|
|
162
|
+
|
|
163
|
+
if board.config is None:
|
|
164
|
+
board.config = self.get_default_board_config()
|
|
165
|
+
await self.db.commit()
|
|
166
|
+
await self.db.refresh(board)
|
|
167
|
+
|
|
168
|
+
return board
|
|
169
|
+
|
|
170
|
+
async def initialize_all_board_configs(self) -> dict:
|
|
171
|
+
"""Initialize config for all boards that have config=null.
|
|
172
|
+
|
|
173
|
+
Returns a summary of boards that were updated.
|
|
174
|
+
"""
|
|
175
|
+
boards = await self.get_boards()
|
|
176
|
+
updated = []
|
|
177
|
+
skipped = []
|
|
178
|
+
|
|
179
|
+
for board in boards:
|
|
180
|
+
if board.config is None:
|
|
181
|
+
board.config = self.get_default_board_config()
|
|
182
|
+
updated.append(board.id)
|
|
183
|
+
else:
|
|
184
|
+
skipped.append(board.id)
|
|
185
|
+
|
|
186
|
+
if updated:
|
|
187
|
+
await self.db.commit()
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
"total": len(boards),
|
|
191
|
+
"updated": len(updated),
|
|
192
|
+
"skipped": len(skipped),
|
|
193
|
+
"updated_board_ids": updated,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async def get_repo_root(self, board_id: str) -> Path:
|
|
197
|
+
"""Get the repo_root path for a board.
|
|
198
|
+
|
|
199
|
+
CRITICAL: This is the ONLY authoritative way to get a repo path.
|
|
200
|
+
NEVER accept paths from client requests.
|
|
201
|
+
NEVER use global config repo_root when board_id is available.
|
|
202
|
+
"""
|
|
203
|
+
board = await self.get_board_by_id(board_id)
|
|
204
|
+
return Path(board.repo_root).resolve()
|
|
205
|
+
|
|
206
|
+
# =========================================================================
|
|
207
|
+
# Authorization helpers - use these to enforce board boundaries
|
|
208
|
+
# =========================================================================
|
|
209
|
+
|
|
210
|
+
async def verify_goal_in_board(self, goal_id: str, board_id: str) -> Goal:
|
|
211
|
+
"""Verify a goal belongs to a board.
|
|
212
|
+
|
|
213
|
+
Raises ValueError if goal doesn't exist or doesn't belong to board.
|
|
214
|
+
"""
|
|
215
|
+
result = await self.db.execute(select(Goal).where(Goal.id == goal_id))
|
|
216
|
+
goal = result.scalar_one_or_none()
|
|
217
|
+
if not goal:
|
|
218
|
+
raise ValueError(f"Goal not found: {goal_id}")
|
|
219
|
+
|
|
220
|
+
if goal.board_id and goal.board_id != board_id:
|
|
221
|
+
raise ValueError(
|
|
222
|
+
f"Goal {goal_id} belongs to board {goal.board_id}, not {board_id}"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
return goal
|
|
226
|
+
|
|
227
|
+
async def verify_ticket_in_board(self, ticket_id: str, board_id: str) -> Ticket:
|
|
228
|
+
"""Verify a ticket belongs to a board.
|
|
229
|
+
|
|
230
|
+
Raises ValueError if ticket doesn't exist or doesn't belong to board.
|
|
231
|
+
"""
|
|
232
|
+
result = await self.db.execute(select(Ticket).where(Ticket.id == ticket_id))
|
|
233
|
+
ticket = result.scalar_one_or_none()
|
|
234
|
+
if not ticket:
|
|
235
|
+
raise ValueError(f"Ticket not found: {ticket_id}")
|
|
236
|
+
|
|
237
|
+
if ticket.board_id and ticket.board_id != board_id:
|
|
238
|
+
raise ValueError(
|
|
239
|
+
f"Ticket {ticket_id} belongs to board {ticket.board_id}, not {board_id}"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
return ticket
|
|
243
|
+
|
|
244
|
+
async def verify_tickets_in_board(
|
|
245
|
+
self, ticket_ids: list[str], board_id: str
|
|
246
|
+
) -> list[Ticket]:
|
|
247
|
+
"""Verify multiple tickets belong to a board.
|
|
248
|
+
|
|
249
|
+
Raises ValueError if any ticket doesn't exist or doesn't belong.
|
|
250
|
+
"""
|
|
251
|
+
tickets = []
|
|
252
|
+
for ticket_id in ticket_ids:
|
|
253
|
+
ticket = await self.verify_ticket_in_board(ticket_id, board_id)
|
|
254
|
+
tickets.append(ticket)
|
|
255
|
+
return tickets
|
|
256
|
+
|
|
257
|
+
async def verify_job_in_board(self, job_id: str, board_id: str) -> Job:
|
|
258
|
+
"""Verify a job belongs to a board."""
|
|
259
|
+
result = await self.db.execute(select(Job).where(Job.id == job_id))
|
|
260
|
+
job = result.scalar_one_or_none()
|
|
261
|
+
if not job:
|
|
262
|
+
raise ValueError(f"Job not found: {job_id}")
|
|
263
|
+
|
|
264
|
+
if job.board_id and job.board_id != board_id:
|
|
265
|
+
raise ValueError(
|
|
266
|
+
f"Job {job_id} belongs to board {job.board_id}, not {board_id}"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
return job
|
|
270
|
+
|
|
271
|
+
async def verify_workspace_in_board(
|
|
272
|
+
self, workspace_id: str, board_id: str
|
|
273
|
+
) -> Workspace:
|
|
274
|
+
"""Verify a workspace belongs to a board."""
|
|
275
|
+
result = await self.db.execute(
|
|
276
|
+
select(Workspace).where(Workspace.id == workspace_id)
|
|
277
|
+
)
|
|
278
|
+
workspace = result.scalar_one_or_none()
|
|
279
|
+
if not workspace:
|
|
280
|
+
raise ValueError(f"Workspace not found: {workspace_id}")
|
|
281
|
+
|
|
282
|
+
if workspace.board_id and workspace.board_id != board_id:
|
|
283
|
+
raise ValueError(
|
|
284
|
+
f"Workspace {workspace_id} belongs to board {workspace.board_id}, "
|
|
285
|
+
f"not {board_id}"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
return workspace
|
|
289
|
+
|
|
290
|
+
async def verify_path_under_repo_root(
|
|
291
|
+
self, path: str | Path, board_id: str
|
|
292
|
+
) -> Path:
|
|
293
|
+
"""Verify a path is under the board's repo_root.
|
|
294
|
+
|
|
295
|
+
CRITICAL: Use this to validate any filesystem paths before operations.
|
|
296
|
+
Prevents directory traversal attacks.
|
|
297
|
+
"""
|
|
298
|
+
repo_root = await self.get_repo_root(board_id)
|
|
299
|
+
target_path = Path(path).resolve()
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
target_path.relative_to(repo_root)
|
|
303
|
+
except ValueError:
|
|
304
|
+
raise ValueError(
|
|
305
|
+
f"Path {target_path} is not under board repo_root {repo_root}"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
return target_path
|
|
309
|
+
|
|
310
|
+
async def get_board_for_goal(self, goal_id: str) -> Board | None:
|
|
311
|
+
"""Get the board that owns a goal (if any)."""
|
|
312
|
+
result = await self.db.execute(select(Goal).where(Goal.id == goal_id))
|
|
313
|
+
goal = result.scalar_one_or_none()
|
|
314
|
+
if not goal or not goal.board_id:
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
return await self.get_board_by_id(goal.board_id)
|
|
318
|
+
|
|
319
|
+
async def get_board_for_ticket(self, ticket_id: str) -> Board | None:
|
|
320
|
+
"""Get the board that owns a ticket (if any)."""
|
|
321
|
+
result = await self.db.execute(select(Ticket).where(Ticket.id == ticket_id))
|
|
322
|
+
ticket = result.scalar_one_or_none()
|
|
323
|
+
if not ticket or not ticket.board_id:
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
return await self.get_board_by_id(ticket.board_id)
|