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,486 @@
|
|
|
1
|
+
"""Service layer for Ticket operations with state machine enforcement."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import select
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
|
+
from sqlalchemy.orm import selectinload
|
|
12
|
+
|
|
13
|
+
from app.database_sync import get_sync_db
|
|
14
|
+
from app.exceptions import InvalidStateTransitionError, ResourceNotFoundError
|
|
15
|
+
from app.models.board import Board
|
|
16
|
+
from app.models.ticket import Ticket
|
|
17
|
+
from app.models.ticket_event import TicketEvent
|
|
18
|
+
from app.schemas.ticket import TicketCreate, TicketResponse, TicketsByState
|
|
19
|
+
from app.services.webhook_service import fire_webhooks
|
|
20
|
+
from app.services.workspace_service import WorkspaceService
|
|
21
|
+
from app.state_machine import (
|
|
22
|
+
ActorType,
|
|
23
|
+
EventType,
|
|
24
|
+
TicketState,
|
|
25
|
+
is_terminal_state,
|
|
26
|
+
validate_transition,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# Thread pool for running blocking workspace cleanup operations
|
|
32
|
+
_cleanup_executor = ThreadPoolExecutor(
|
|
33
|
+
max_workers=2, thread_name_prefix="workspace_cleanup"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TicketService:
|
|
38
|
+
"""Service class for Ticket business logic with state machine enforcement."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, db: AsyncSession):
|
|
41
|
+
self.db = db
|
|
42
|
+
|
|
43
|
+
async def create_ticket(self, data: TicketCreate) -> Ticket:
|
|
44
|
+
"""
|
|
45
|
+
Create a new ticket in the 'proposed' state.
|
|
46
|
+
Also creates an initial TicketEvent for the creation.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
data: Ticket creation data
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The created Ticket instance
|
|
53
|
+
"""
|
|
54
|
+
# Validate blocked_by_ticket_id if provided
|
|
55
|
+
blocked_by_title = None
|
|
56
|
+
if data.blocked_by_ticket_id:
|
|
57
|
+
result = await self.db.execute(
|
|
58
|
+
select(Ticket).where(Ticket.id == data.blocked_by_ticket_id)
|
|
59
|
+
)
|
|
60
|
+
blocker = result.scalar_one_or_none()
|
|
61
|
+
if not blocker:
|
|
62
|
+
raise ResourceNotFoundError(
|
|
63
|
+
"Blocking Ticket", data.blocked_by_ticket_id
|
|
64
|
+
)
|
|
65
|
+
blocked_by_title = blocker.title
|
|
66
|
+
|
|
67
|
+
# Fetch board_id from the parent goal
|
|
68
|
+
from app.models.goal import Goal
|
|
69
|
+
|
|
70
|
+
goal = await self.db.get(Goal, data.goal_id)
|
|
71
|
+
if not goal:
|
|
72
|
+
raise ResourceNotFoundError("Goal", data.goal_id)
|
|
73
|
+
|
|
74
|
+
# Create the ticket
|
|
75
|
+
ticket = Ticket(
|
|
76
|
+
goal_id=data.goal_id,
|
|
77
|
+
board_id=goal.board_id,
|
|
78
|
+
title=data.title,
|
|
79
|
+
description=data.description,
|
|
80
|
+
state=TicketState.PROPOSED.value,
|
|
81
|
+
priority=data.priority,
|
|
82
|
+
blocked_by_ticket_id=data.blocked_by_ticket_id,
|
|
83
|
+
)
|
|
84
|
+
self.db.add(ticket)
|
|
85
|
+
await self.db.flush()
|
|
86
|
+
|
|
87
|
+
# Create the initial event
|
|
88
|
+
event = TicketEvent(
|
|
89
|
+
ticket_id=ticket.id,
|
|
90
|
+
event_type=EventType.CREATED.value,
|
|
91
|
+
from_state=None,
|
|
92
|
+
to_state=TicketState.PROPOSED.value,
|
|
93
|
+
actor_type=data.actor_type.value,
|
|
94
|
+
actor_id=data.actor_id,
|
|
95
|
+
reason="Ticket created",
|
|
96
|
+
payload_json=json.dumps(
|
|
97
|
+
{
|
|
98
|
+
"title": data.title,
|
|
99
|
+
"description": data.description,
|
|
100
|
+
"goal_id": data.goal_id,
|
|
101
|
+
"priority": data.priority,
|
|
102
|
+
"blocked_by_ticket_id": data.blocked_by_ticket_id,
|
|
103
|
+
"blocked_by_title": blocked_by_title,
|
|
104
|
+
}
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
self.db.add(event)
|
|
108
|
+
await self.db.flush()
|
|
109
|
+
await self.db.refresh(ticket)
|
|
110
|
+
|
|
111
|
+
return ticket
|
|
112
|
+
|
|
113
|
+
async def get_ticket_by_id(self, ticket_id: str) -> Ticket:
|
|
114
|
+
"""
|
|
115
|
+
Get a ticket by its ID.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
ticket_id: The UUID of the ticket
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
The Ticket instance
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
ResourceNotFoundError: If the ticket is not found
|
|
125
|
+
"""
|
|
126
|
+
result = await self.db.execute(
|
|
127
|
+
select(Ticket)
|
|
128
|
+
.where(Ticket.id == ticket_id)
|
|
129
|
+
.options(
|
|
130
|
+
selectinload(Ticket.goal),
|
|
131
|
+
selectinload(Ticket.blocked_by), # Load blocker relationship
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
ticket = result.scalar_one_or_none()
|
|
135
|
+
if ticket is None:
|
|
136
|
+
raise ResourceNotFoundError("Ticket", ticket_id)
|
|
137
|
+
return ticket
|
|
138
|
+
|
|
139
|
+
async def transition_ticket(
|
|
140
|
+
self,
|
|
141
|
+
ticket_id: str,
|
|
142
|
+
to_state: TicketState,
|
|
143
|
+
actor_type: ActorType,
|
|
144
|
+
actor_id: str | None = None,
|
|
145
|
+
reason: str | None = None,
|
|
146
|
+
auto_verify: bool = True,
|
|
147
|
+
skip_cleanup: bool = False,
|
|
148
|
+
) -> Ticket:
|
|
149
|
+
"""
|
|
150
|
+
Transition a ticket to a new state.
|
|
151
|
+
Validates the transition and creates a TicketEvent atomically.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
ticket_id: The UUID of the ticket
|
|
155
|
+
to_state: The target state
|
|
156
|
+
actor_type: The type of actor performing the transition
|
|
157
|
+
actor_id: Optional ID of the actor
|
|
158
|
+
reason: Optional reason for the transition
|
|
159
|
+
auto_verify: If True, auto-enqueue verify job when entering verifying state
|
|
160
|
+
skip_cleanup: If True, skip workspace cleanup for terminal states
|
|
161
|
+
(use when caller handles cleanup separately to avoid SQLite deadlocks)
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
The updated Ticket instance
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
ResourceNotFoundError: If the ticket is not found
|
|
168
|
+
InvalidStateTransitionError: If the transition is not valid
|
|
169
|
+
"""
|
|
170
|
+
ticket = await self.get_ticket_by_id(ticket_id)
|
|
171
|
+
from_state = TicketState(ticket.state)
|
|
172
|
+
|
|
173
|
+
# Validate the transition
|
|
174
|
+
if not validate_transition(from_state, to_state):
|
|
175
|
+
raise InvalidStateTransitionError(from_state.value, to_state.value)
|
|
176
|
+
|
|
177
|
+
# Update the ticket state
|
|
178
|
+
ticket.state = to_state.value
|
|
179
|
+
|
|
180
|
+
# Create the transition event
|
|
181
|
+
event = TicketEvent(
|
|
182
|
+
ticket_id=ticket.id,
|
|
183
|
+
event_type=EventType.TRANSITIONED.value,
|
|
184
|
+
from_state=from_state.value,
|
|
185
|
+
to_state=to_state.value,
|
|
186
|
+
actor_type=actor_type.value,
|
|
187
|
+
actor_id=actor_id,
|
|
188
|
+
reason=reason,
|
|
189
|
+
payload_json=None,
|
|
190
|
+
)
|
|
191
|
+
self.db.add(event)
|
|
192
|
+
|
|
193
|
+
await self.db.flush()
|
|
194
|
+
await self.db.refresh(ticket)
|
|
195
|
+
|
|
196
|
+
# Trigger workspace cleanup for terminal states.
|
|
197
|
+
# Note: DONE can transition back to EXECUTING (human requests changes),
|
|
198
|
+
# in which case WorkspaceService.ensure_workspace() will recreate it.
|
|
199
|
+
if is_terminal_state(to_state) and not skip_cleanup:
|
|
200
|
+
await self._cleanup_workspace_async(ticket_id)
|
|
201
|
+
|
|
202
|
+
# Auto-trigger verification when entering verifying state
|
|
203
|
+
if auto_verify and to_state == TicketState.VERIFYING:
|
|
204
|
+
await self._enqueue_verify_job_async(ticket_id)
|
|
205
|
+
|
|
206
|
+
# Fire webhook notifications (best-effort, non-blocking)
|
|
207
|
+
try:
|
|
208
|
+
board = await self._get_board_for_ticket(ticket)
|
|
209
|
+
webhooks = (board.config or {}).get("webhooks", []) if board else []
|
|
210
|
+
if webhooks:
|
|
211
|
+
asyncio.ensure_future(
|
|
212
|
+
fire_webhooks(
|
|
213
|
+
webhooks,
|
|
214
|
+
ticket_id=ticket.id,
|
|
215
|
+
ticket_title=ticket.title,
|
|
216
|
+
board_id=ticket.board_id,
|
|
217
|
+
from_state=from_state.value,
|
|
218
|
+
to_state=to_state.value,
|
|
219
|
+
actor_type=actor_type.value,
|
|
220
|
+
actor_id=actor_id,
|
|
221
|
+
reason=reason,
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
except Exception:
|
|
225
|
+
logger.warning(
|
|
226
|
+
"Failed to dispatch webhooks for ticket %s", ticket_id, exc_info=True
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
return ticket
|
|
230
|
+
|
|
231
|
+
async def _get_board_for_ticket(self, ticket: Ticket) -> Board | None:
|
|
232
|
+
"""Load the board for a ticket (for webhook config)."""
|
|
233
|
+
result = await self.db.execute(select(Board).where(Board.id == ticket.board_id))
|
|
234
|
+
return result.scalar_one_or_none()
|
|
235
|
+
|
|
236
|
+
async def _enqueue_verify_job_async(self, ticket_id: str) -> str | None:
|
|
237
|
+
"""
|
|
238
|
+
Asynchronously enqueue a verify job for a ticket (idempotent).
|
|
239
|
+
|
|
240
|
+
Idempotency: Only creates a new verify job if there is no active
|
|
241
|
+
(queued or running) verify job for this ticket. This prevents
|
|
242
|
+
duplicate verify jobs from race conditions or retries.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
ticket_id: The UUID of the ticket
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
The job ID if created, None if skipped (already active).
|
|
249
|
+
"""
|
|
250
|
+
from app.models.job import Job, JobKind, JobStatus
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
# IDEMPOTENCY CHECK: Is there already an active verify job?
|
|
254
|
+
active_verify_result = await self.db.execute(
|
|
255
|
+
select(Job).where(
|
|
256
|
+
Job.ticket_id == ticket_id,
|
|
257
|
+
Job.kind == JobKind.VERIFY.value,
|
|
258
|
+
Job.status.in_([JobStatus.QUEUED.value, JobStatus.RUNNING.value]),
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
active_verify = active_verify_result.scalar_one_or_none()
|
|
262
|
+
|
|
263
|
+
if active_verify:
|
|
264
|
+
logger.info(
|
|
265
|
+
f"Skipping verify enqueue for ticket {ticket_id} - already has active job {active_verify.id}"
|
|
266
|
+
)
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
# Create the job record
|
|
270
|
+
job = Job(
|
|
271
|
+
ticket_id=ticket_id,
|
|
272
|
+
kind=JobKind.VERIFY.value,
|
|
273
|
+
status=JobStatus.QUEUED.value,
|
|
274
|
+
)
|
|
275
|
+
self.db.add(job)
|
|
276
|
+
await self.db.flush()
|
|
277
|
+
await self.db.refresh(job)
|
|
278
|
+
|
|
279
|
+
# CRITICAL: Commit BEFORE dispatching to avoid SQLite deadlock.
|
|
280
|
+
# The SQLite in-process worker picks up tasks immediately and tries
|
|
281
|
+
# to write to the same DB. If we hold the write lock (uncommitted
|
|
282
|
+
# flush), the worker blocks → deadlock with the event loop.
|
|
283
|
+
await self.db.commit()
|
|
284
|
+
|
|
285
|
+
# Enqueue the verify task via unified dispatch
|
|
286
|
+
from app.services.task_dispatch import enqueue_task
|
|
287
|
+
|
|
288
|
+
task = enqueue_task("verify_ticket", args=[job.id])
|
|
289
|
+
|
|
290
|
+
# Store the task ID (new transaction after commit)
|
|
291
|
+
job.celery_task_id = task.id
|
|
292
|
+
await self.db.flush()
|
|
293
|
+
|
|
294
|
+
logger.info(f"Auto-enqueued verify job {job.id} for ticket {ticket_id}")
|
|
295
|
+
return job.id
|
|
296
|
+
|
|
297
|
+
except Exception as e:
|
|
298
|
+
logger.error(
|
|
299
|
+
f"Failed to auto-enqueue verify job for ticket {ticket_id}: {e}"
|
|
300
|
+
)
|
|
301
|
+
return None
|
|
302
|
+
|
|
303
|
+
async def _cleanup_workspace_async(self, ticket_id: str) -> None:
|
|
304
|
+
"""
|
|
305
|
+
Clean up the workspace for a ticket asynchronously.
|
|
306
|
+
|
|
307
|
+
Runs the blocking git operations in a thread pool executor.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
ticket_id: The UUID of the ticket
|
|
311
|
+
"""
|
|
312
|
+
loop = asyncio.get_event_loop()
|
|
313
|
+
try:
|
|
314
|
+
await loop.run_in_executor(
|
|
315
|
+
_cleanup_executor,
|
|
316
|
+
self._cleanup_workspace_sync,
|
|
317
|
+
ticket_id,
|
|
318
|
+
)
|
|
319
|
+
except Exception as e:
|
|
320
|
+
# Log but don't fail the transition if cleanup fails
|
|
321
|
+
logger.warning(f"Failed to cleanup workspace for ticket {ticket_id}: {e}")
|
|
322
|
+
|
|
323
|
+
@staticmethod
|
|
324
|
+
def _cleanup_workspace_sync(ticket_id: str) -> bool:
|
|
325
|
+
"""
|
|
326
|
+
Synchronously clean up the workspace for a ticket.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
ticket_id: The UUID of the ticket
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
True if cleanup was performed, False otherwise
|
|
333
|
+
"""
|
|
334
|
+
try:
|
|
335
|
+
with get_sync_db() as db:
|
|
336
|
+
workspace_service = WorkspaceService(db)
|
|
337
|
+
return workspace_service.cleanup_worktree(ticket_id)
|
|
338
|
+
except Exception as e:
|
|
339
|
+
logger.warning(f"Workspace cleanup error for ticket {ticket_id}: {e}")
|
|
340
|
+
return False
|
|
341
|
+
|
|
342
|
+
async def get_board(self, board_id: str | None = None) -> list[TicketsByState]:
|
|
343
|
+
"""
|
|
344
|
+
Get tickets grouped by state for the board view.
|
|
345
|
+
Tickets are ordered by sort_order first (if set), then priority
|
|
346
|
+
(descending, nulls last) within each state.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
board_id: Optional board ID to filter tickets. If None, returns all tickets.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
List of TicketsByState objects
|
|
353
|
+
"""
|
|
354
|
+
query = (
|
|
355
|
+
select(Ticket)
|
|
356
|
+
.options(selectinload(Ticket.goal)) # Eagerly load goal to avoid N+1
|
|
357
|
+
.options(selectinload(Ticket.blocked_by)) # Eagerly load blocker ticket
|
|
358
|
+
.order_by(
|
|
359
|
+
Ticket.sort_order.asc().nulls_last(),
|
|
360
|
+
Ticket.priority.desc().nulls_last(),
|
|
361
|
+
Ticket.created_at.desc(),
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# Filter by board_id if provided
|
|
366
|
+
if board_id is not None:
|
|
367
|
+
query = query.where(Ticket.board_id == board_id)
|
|
368
|
+
|
|
369
|
+
result = await self.db.execute(query)
|
|
370
|
+
tickets = result.scalars().all()
|
|
371
|
+
|
|
372
|
+
# Group tickets by state
|
|
373
|
+
tickets_by_state: dict[str, list[Ticket]] = defaultdict(list)
|
|
374
|
+
for ticket in tickets:
|
|
375
|
+
tickets_by_state[ticket.state].append(ticket)
|
|
376
|
+
|
|
377
|
+
# Build response with all states (even empty ones)
|
|
378
|
+
columns = []
|
|
379
|
+
for state in TicketState:
|
|
380
|
+
# Convert tickets to response format and add blocker titles
|
|
381
|
+
ticket_list = tickets_by_state.get(state.value, [])
|
|
382
|
+
ticket_responses = []
|
|
383
|
+
for ticket in ticket_list:
|
|
384
|
+
ticket_dict = TicketResponse.model_validate(ticket).model_dump()
|
|
385
|
+
# Add blocker title if ticket is blocked
|
|
386
|
+
if ticket.blocked_by_ticket_id and ticket.blocked_by:
|
|
387
|
+
ticket_dict["blocked_by_ticket_title"] = ticket.blocked_by.title
|
|
388
|
+
# Add goal title if goal is loaded
|
|
389
|
+
if ticket.goal:
|
|
390
|
+
ticket_dict["goal_title"] = ticket.goal.title
|
|
391
|
+
ticket_responses.append(ticket_dict)
|
|
392
|
+
|
|
393
|
+
columns.append(
|
|
394
|
+
TicketsByState(
|
|
395
|
+
state=state,
|
|
396
|
+
tickets=ticket_responses,
|
|
397
|
+
)
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
return columns
|
|
401
|
+
|
|
402
|
+
async def get_ticket_events(self, ticket_id: str) -> list[TicketEvent]:
|
|
403
|
+
"""
|
|
404
|
+
Get all events for a ticket.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
ticket_id: The UUID of the ticket
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
List of TicketEvent instances ordered by created_at
|
|
411
|
+
|
|
412
|
+
Raises:
|
|
413
|
+
ResourceNotFoundError: If the ticket is not found
|
|
414
|
+
"""
|
|
415
|
+
# First verify the ticket exists
|
|
416
|
+
await self.get_ticket_by_id(ticket_id)
|
|
417
|
+
|
|
418
|
+
result = await self.db.execute(
|
|
419
|
+
select(TicketEvent)
|
|
420
|
+
.where(TicketEvent.ticket_id == ticket_id)
|
|
421
|
+
.order_by(TicketEvent.created_at.asc())
|
|
422
|
+
)
|
|
423
|
+
return list(result.scalars().all())
|
|
424
|
+
|
|
425
|
+
async def delete_all_tickets(self, board_id: str | None = None) -> int:
|
|
426
|
+
"""
|
|
427
|
+
Delete all tickets from the database.
|
|
428
|
+
|
|
429
|
+
This will cascade delete all associated:
|
|
430
|
+
- Jobs
|
|
431
|
+
- Revisions (and their review comments/summaries)
|
|
432
|
+
- Ticket events
|
|
433
|
+
- Workspaces
|
|
434
|
+
- Evidence
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
board_id: Optional board ID to limit deletion to specific board
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
Number of tickets deleted
|
|
441
|
+
"""
|
|
442
|
+
from sqlalchemy import delete
|
|
443
|
+
|
|
444
|
+
# Build query
|
|
445
|
+
query = select(Ticket)
|
|
446
|
+
if board_id:
|
|
447
|
+
query = query.where(Ticket.board_id == board_id)
|
|
448
|
+
|
|
449
|
+
# Get all ticket IDs for workspace cleanup
|
|
450
|
+
result = await self.db.execute(query)
|
|
451
|
+
tickets = result.scalars().all()
|
|
452
|
+
ticket_ids = [t.id for t in tickets]
|
|
453
|
+
count = len(ticket_ids)
|
|
454
|
+
|
|
455
|
+
if count == 0:
|
|
456
|
+
return 0
|
|
457
|
+
|
|
458
|
+
# Clean up workspaces asynchronously (best effort)
|
|
459
|
+
cleanup_tasks = []
|
|
460
|
+
for ticket_id in ticket_ids:
|
|
461
|
+
task = asyncio.create_task(self._cleanup_workspace_async(ticket_id))
|
|
462
|
+
cleanup_tasks.append(task)
|
|
463
|
+
|
|
464
|
+
# Wait for cleanup with timeout (don't block deletion if cleanup fails)
|
|
465
|
+
if cleanup_tasks:
|
|
466
|
+
try:
|
|
467
|
+
await asyncio.wait_for(
|
|
468
|
+
asyncio.gather(*cleanup_tasks, return_exceptions=True), timeout=30.0
|
|
469
|
+
)
|
|
470
|
+
except TimeoutError:
|
|
471
|
+
logger.warning(
|
|
472
|
+
"Workspace cleanup timed out during bulk ticket deletion"
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
# Delete all tickets (cascade will handle related records)
|
|
476
|
+
delete_query = delete(Ticket)
|
|
477
|
+
if board_id:
|
|
478
|
+
delete_query = delete_query.where(Ticket.board_id == board_id)
|
|
479
|
+
|
|
480
|
+
await self.db.execute(delete_query)
|
|
481
|
+
await self.db.commit()
|
|
482
|
+
|
|
483
|
+
logger.info(
|
|
484
|
+
f"Deleted {count} tickets" + (f" from board {board_id}" if board_id else "")
|
|
485
|
+
)
|
|
486
|
+
return count
|