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,223 @@
|
|
|
1
|
+
"""Ticket model for Draft."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
|
9
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
10
|
+
|
|
11
|
+
from app.models.base import Base
|
|
12
|
+
from app.state_machine import TicketState
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from app.models.agent_session import AgentSession
|
|
16
|
+
from app.models.board import Board
|
|
17
|
+
from app.models.evidence import Evidence
|
|
18
|
+
from app.models.goal import Goal
|
|
19
|
+
from app.models.job import Job
|
|
20
|
+
from app.models.revision import Revision
|
|
21
|
+
from app.models.ticket_event import TicketEvent
|
|
22
|
+
from app.models.workspace import Workspace
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Ticket(Base):
|
|
26
|
+
"""Ticket model representing a unit of work.
|
|
27
|
+
|
|
28
|
+
IMPORTANT: Tickets are scoped by board_id for permission enforcement.
|
|
29
|
+
The board_id should match the goal's board_id.
|
|
30
|
+
|
|
31
|
+
BLOCKING/DEPENDENCIES:
|
|
32
|
+
- blocked_by_ticket_id: If set, this ticket is blocked by another ticket
|
|
33
|
+
- A ticket cannot be queued for execution until its blocker is DONE
|
|
34
|
+
- When generating tickets, the agent can specify dependencies
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
__tablename__ = "tickets"
|
|
38
|
+
|
|
39
|
+
id: Mapped[str] = mapped_column(
|
|
40
|
+
String(36),
|
|
41
|
+
primary_key=True,
|
|
42
|
+
default=lambda: str(uuid.uuid4()),
|
|
43
|
+
)
|
|
44
|
+
board_id: Mapped[str | None] = mapped_column(
|
|
45
|
+
String(36),
|
|
46
|
+
ForeignKey("boards.id", ondelete="CASCADE"),
|
|
47
|
+
nullable=True, # Nullable for migration compatibility
|
|
48
|
+
index=True,
|
|
49
|
+
)
|
|
50
|
+
goal_id: Mapped[str] = mapped_column(
|
|
51
|
+
String(36),
|
|
52
|
+
ForeignKey("goals.id", ondelete="CASCADE"),
|
|
53
|
+
nullable=False,
|
|
54
|
+
index=True,
|
|
55
|
+
)
|
|
56
|
+
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
57
|
+
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
58
|
+
state: Mapped[str] = mapped_column(
|
|
59
|
+
String(50),
|
|
60
|
+
default=TicketState.PROPOSED.value,
|
|
61
|
+
nullable=False,
|
|
62
|
+
index=True,
|
|
63
|
+
)
|
|
64
|
+
priority: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
65
|
+
sort_order: Mapped[int | None] = mapped_column(
|
|
66
|
+
Integer,
|
|
67
|
+
nullable=True,
|
|
68
|
+
index=True,
|
|
69
|
+
doc="Manual sort order within a state column (lower = higher position)",
|
|
70
|
+
)
|
|
71
|
+
verification_commands_json: Mapped[str | None] = mapped_column(
|
|
72
|
+
Text, nullable=True, doc="JSON array of verification commands"
|
|
73
|
+
)
|
|
74
|
+
# Blocking/dependency: If set, this ticket cannot be executed until the blocker is DONE
|
|
75
|
+
blocked_by_ticket_id: Mapped[str | None] = mapped_column(
|
|
76
|
+
String(36),
|
|
77
|
+
ForeignKey("tickets.id", ondelete="SET NULL"),
|
|
78
|
+
nullable=True,
|
|
79
|
+
index=True,
|
|
80
|
+
)
|
|
81
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
82
|
+
DateTime,
|
|
83
|
+
server_default=func.now(),
|
|
84
|
+
nullable=False,
|
|
85
|
+
)
|
|
86
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
87
|
+
DateTime,
|
|
88
|
+
server_default=func.now(),
|
|
89
|
+
onupdate=func.now(),
|
|
90
|
+
nullable=False,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# GitHub Pull Request fields
|
|
94
|
+
pr_number: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
|
95
|
+
pr_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
96
|
+
pr_state: Mapped[str | None] = mapped_column(
|
|
97
|
+
String(20), nullable=True
|
|
98
|
+
) # 'OPEN', 'CLOSED', 'MERGED'
|
|
99
|
+
pr_created_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
100
|
+
pr_merged_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
101
|
+
pr_head_branch: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
102
|
+
pr_base_branch: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
103
|
+
|
|
104
|
+
# Relationships
|
|
105
|
+
board: Mapped["Board | None"] = relationship("Board", back_populates="tickets")
|
|
106
|
+
goal: Mapped["Goal"] = relationship("Goal", back_populates="tickets")
|
|
107
|
+
events: Mapped[list["TicketEvent"]] = relationship(
|
|
108
|
+
"TicketEvent",
|
|
109
|
+
back_populates="ticket",
|
|
110
|
+
cascade="all, delete-orphan",
|
|
111
|
+
order_by="TicketEvent.created_at",
|
|
112
|
+
)
|
|
113
|
+
jobs: Mapped[list["Job"]] = relationship(
|
|
114
|
+
"Job",
|
|
115
|
+
back_populates="ticket",
|
|
116
|
+
cascade="all, delete-orphan",
|
|
117
|
+
order_by="Job.created_at.desc()",
|
|
118
|
+
)
|
|
119
|
+
workspace: Mapped["Workspace | None"] = relationship(
|
|
120
|
+
"Workspace",
|
|
121
|
+
back_populates="ticket",
|
|
122
|
+
cascade="all, delete-orphan",
|
|
123
|
+
uselist=False,
|
|
124
|
+
)
|
|
125
|
+
evidence: Mapped[list["Evidence"]] = relationship(
|
|
126
|
+
"Evidence",
|
|
127
|
+
back_populates="ticket",
|
|
128
|
+
cascade="all, delete-orphan",
|
|
129
|
+
order_by="Evidence.created_at.desc()",
|
|
130
|
+
)
|
|
131
|
+
revisions: Mapped[list["Revision"]] = relationship(
|
|
132
|
+
"Revision",
|
|
133
|
+
back_populates="ticket",
|
|
134
|
+
cascade="all, delete-orphan",
|
|
135
|
+
order_by="Revision.number.desc()",
|
|
136
|
+
)
|
|
137
|
+
agent_sessions: Mapped[list["AgentSession"]] = relationship(
|
|
138
|
+
"AgentSession",
|
|
139
|
+
back_populates="ticket",
|
|
140
|
+
cascade="all, delete-orphan",
|
|
141
|
+
order_by="AgentSession.created_at.desc()",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Blocking relationship (self-referential)
|
|
145
|
+
blocked_by: Mapped["Ticket | None"] = relationship(
|
|
146
|
+
"Ticket",
|
|
147
|
+
foreign_keys=[blocked_by_ticket_id],
|
|
148
|
+
remote_side="Ticket.id",
|
|
149
|
+
uselist=False,
|
|
150
|
+
back_populates="blocking",
|
|
151
|
+
)
|
|
152
|
+
# Tickets that this ticket is blocking
|
|
153
|
+
blocking: Mapped[list["Ticket"]] = relationship(
|
|
154
|
+
"Ticket",
|
|
155
|
+
foreign_keys="Ticket.blocked_by_ticket_id",
|
|
156
|
+
back_populates="blocked_by",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def state_enum(self) -> TicketState:
|
|
161
|
+
"""Get the state as a TicketState enum."""
|
|
162
|
+
return TicketState(self.state)
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def is_blocked_by_dependency(self) -> bool:
|
|
166
|
+
"""Check if this ticket is blocked by an incomplete dependency.
|
|
167
|
+
|
|
168
|
+
Returns True if:
|
|
169
|
+
- blocked_by_ticket_id is set, AND
|
|
170
|
+
- The blocking ticket's state is NOT 'done'
|
|
171
|
+
|
|
172
|
+
Note: This requires the blocked_by relationship to be loaded.
|
|
173
|
+
Use selectinload(Ticket.blocked_by) when querying.
|
|
174
|
+
"""
|
|
175
|
+
if not self.blocked_by_ticket_id:
|
|
176
|
+
return False
|
|
177
|
+
if self.blocked_by is None:
|
|
178
|
+
# Relationship not loaded - assume blocked for safety
|
|
179
|
+
return True
|
|
180
|
+
return self.blocked_by.state != TicketState.DONE.value
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def verification_commands(self) -> list[str]:
|
|
184
|
+
"""Get verification commands as a list."""
|
|
185
|
+
if not self.verification_commands_json:
|
|
186
|
+
return []
|
|
187
|
+
try:
|
|
188
|
+
return json.loads(self.verification_commands_json)
|
|
189
|
+
except (json.JSONDecodeError, TypeError):
|
|
190
|
+
return []
|
|
191
|
+
|
|
192
|
+
@verification_commands.setter
|
|
193
|
+
def verification_commands(self, commands: list[str]) -> None:
|
|
194
|
+
"""Set verification commands from a list with validation."""
|
|
195
|
+
if not commands:
|
|
196
|
+
self.verification_commands_json = None
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
# Validation constants
|
|
200
|
+
MAX_COMMANDS = 5
|
|
201
|
+
MAX_CMD_LENGTH = 500
|
|
202
|
+
|
|
203
|
+
# Validate and sanitize
|
|
204
|
+
validated = []
|
|
205
|
+
for cmd in commands[:MAX_COMMANDS]:
|
|
206
|
+
if not isinstance(cmd, str):
|
|
207
|
+
continue
|
|
208
|
+
# Truncate if too long
|
|
209
|
+
cmd = cmd[:MAX_CMD_LENGTH].strip()
|
|
210
|
+
# Skip empty commands
|
|
211
|
+
if not cmd:
|
|
212
|
+
continue
|
|
213
|
+
# Remove null bytes and control chars
|
|
214
|
+
cmd = "".join(c for c in cmd if ord(c) >= 32 or c in "\t\n\r")
|
|
215
|
+
validated.append(cmd)
|
|
216
|
+
|
|
217
|
+
if validated:
|
|
218
|
+
self.verification_commands_json = json.dumps(validated)
|
|
219
|
+
else:
|
|
220
|
+
self.verification_commands_json = None
|
|
221
|
+
|
|
222
|
+
def __repr__(self) -> str:
|
|
223
|
+
return f"<Ticket(id={self.id}, title={self.title}, state={self.state})>"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""TicketEvent model for Draft - append-only event log."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
|
8
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
9
|
+
|
|
10
|
+
from app.models.base import Base
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from app.models.ticket import Ticket
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TicketEvent(Base):
|
|
17
|
+
"""
|
|
18
|
+
TicketEvent model for recording all ticket state changes and actions.
|
|
19
|
+
|
|
20
|
+
This is an append-only log - events should never be updated or deleted.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
__tablename__ = "ticket_events"
|
|
24
|
+
|
|
25
|
+
id: Mapped[str] = mapped_column(
|
|
26
|
+
String(36),
|
|
27
|
+
primary_key=True,
|
|
28
|
+
default=lambda: str(uuid.uuid4()),
|
|
29
|
+
)
|
|
30
|
+
ticket_id: Mapped[str] = mapped_column(
|
|
31
|
+
String(36),
|
|
32
|
+
ForeignKey("tickets.id", ondelete="CASCADE"),
|
|
33
|
+
nullable=False,
|
|
34
|
+
index=True,
|
|
35
|
+
)
|
|
36
|
+
event_type: Mapped[str] = mapped_column(
|
|
37
|
+
String(50),
|
|
38
|
+
nullable=False,
|
|
39
|
+
)
|
|
40
|
+
from_state: Mapped[str | None] = mapped_column(
|
|
41
|
+
String(50),
|
|
42
|
+
nullable=True,
|
|
43
|
+
)
|
|
44
|
+
to_state: Mapped[str | None] = mapped_column(
|
|
45
|
+
String(50),
|
|
46
|
+
nullable=True,
|
|
47
|
+
)
|
|
48
|
+
actor_type: Mapped[str] = mapped_column(
|
|
49
|
+
String(50),
|
|
50
|
+
nullable=False,
|
|
51
|
+
)
|
|
52
|
+
actor_id: Mapped[str | None] = mapped_column(
|
|
53
|
+
String(255),
|
|
54
|
+
nullable=True,
|
|
55
|
+
)
|
|
56
|
+
reason: Mapped[str | None] = mapped_column(
|
|
57
|
+
Text,
|
|
58
|
+
nullable=True,
|
|
59
|
+
)
|
|
60
|
+
payload_json: Mapped[str | None] = mapped_column(
|
|
61
|
+
Text,
|
|
62
|
+
nullable=True,
|
|
63
|
+
)
|
|
64
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
65
|
+
DateTime,
|
|
66
|
+
server_default=func.now(),
|
|
67
|
+
nullable=False,
|
|
68
|
+
index=True,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Relationships
|
|
72
|
+
ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="events")
|
|
73
|
+
|
|
74
|
+
def get_payload(self) -> dict[str, Any] | None:
|
|
75
|
+
"""Parse and return the payload as a dictionary."""
|
|
76
|
+
if self.payload_json is None:
|
|
77
|
+
return None
|
|
78
|
+
import json
|
|
79
|
+
|
|
80
|
+
return json.loads(self.payload_json)
|
|
81
|
+
|
|
82
|
+
def __repr__(self) -> str:
|
|
83
|
+
return f"<TicketEvent(id={self.id}, type={self.event_type}, ticket_id={self.ticket_id})>"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""User model for multi-user authentication."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import Boolean, DateTime, String, func
|
|
7
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
8
|
+
|
|
9
|
+
from app.models.base import Base
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class User(Base):
|
|
13
|
+
"""User account for authentication and board ownership.
|
|
14
|
+
|
|
15
|
+
Each user has their own set of boards. Boards are scoped by owner_id.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
__tablename__ = "users"
|
|
19
|
+
|
|
20
|
+
id: Mapped[str] = mapped_column(
|
|
21
|
+
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
|
22
|
+
)
|
|
23
|
+
email: Mapped[str] = mapped_column(
|
|
24
|
+
String(255), unique=True, nullable=False, index=True
|
|
25
|
+
)
|
|
26
|
+
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
27
|
+
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
28
|
+
is_active: Mapped[bool] = mapped_column(
|
|
29
|
+
Boolean, default=True, server_default="1", nullable=False
|
|
30
|
+
)
|
|
31
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
32
|
+
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
33
|
+
)
|
|
34
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
35
|
+
DateTime(timezone=True),
|
|
36
|
+
server_default=func.now(),
|
|
37
|
+
onupdate=func.now(),
|
|
38
|
+
nullable=False,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Relationships
|
|
42
|
+
boards: Mapped[list["Board"]] = relationship( # noqa: F821
|
|
43
|
+
"Board", back_populates="owner", cascade="all, delete-orphan"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def __repr__(self) -> str:
|
|
47
|
+
return f"<User(id={self.id}, email={self.email})>"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Workspace model for git worktree isolation."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
|
8
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
9
|
+
|
|
10
|
+
from app.models.base import Base
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from app.models.board import Board
|
|
14
|
+
from app.models.ticket import Ticket
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Workspace(Base):
|
|
18
|
+
"""Workspace model representing an isolated git worktree for a ticket.
|
|
19
|
+
|
|
20
|
+
IMPORTANT: Workspaces are scoped by board_id for permission enforcement.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
__tablename__ = "workspaces"
|
|
24
|
+
|
|
25
|
+
id: Mapped[str] = mapped_column(
|
|
26
|
+
String(36),
|
|
27
|
+
primary_key=True,
|
|
28
|
+
default=lambda: str(uuid.uuid4()),
|
|
29
|
+
)
|
|
30
|
+
board_id: Mapped[str | None] = mapped_column(
|
|
31
|
+
String(36),
|
|
32
|
+
ForeignKey("boards.id", ondelete="CASCADE"),
|
|
33
|
+
nullable=True, # Nullable for migration compatibility
|
|
34
|
+
index=True,
|
|
35
|
+
)
|
|
36
|
+
ticket_id: Mapped[str] = mapped_column(
|
|
37
|
+
String(36),
|
|
38
|
+
ForeignKey("tickets.id", ondelete="CASCADE"),
|
|
39
|
+
nullable=False,
|
|
40
|
+
unique=True,
|
|
41
|
+
index=True,
|
|
42
|
+
)
|
|
43
|
+
worktree_path: Mapped[str] = mapped_column(
|
|
44
|
+
Text,
|
|
45
|
+
nullable=False,
|
|
46
|
+
)
|
|
47
|
+
branch_name: Mapped[str] = mapped_column(
|
|
48
|
+
Text,
|
|
49
|
+
nullable=False,
|
|
50
|
+
)
|
|
51
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
52
|
+
DateTime,
|
|
53
|
+
server_default=func.now(),
|
|
54
|
+
nullable=False,
|
|
55
|
+
)
|
|
56
|
+
cleaned_up_at: Mapped[datetime | None] = mapped_column(
|
|
57
|
+
DateTime,
|
|
58
|
+
nullable=True,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Relationships
|
|
62
|
+
board: Mapped["Board | None"] = relationship("Board", back_populates="workspaces")
|
|
63
|
+
ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="workspace")
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def is_active(self) -> bool:
|
|
67
|
+
"""Check if the workspace is still active (not cleaned up)."""
|
|
68
|
+
return self.cleaned_up_at is None
|
|
69
|
+
|
|
70
|
+
def __repr__(self) -> str:
|
|
71
|
+
return f"<Workspace(id={self.id}, ticket_id={self.ticket_id}, branch={self.branch_name})>"
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""In-memory cache for idempotency, rate limiting, and transient data.
|
|
2
|
+
|
|
3
|
+
Provides a simple dict-based cache with TTL support. Used by middleware
|
|
4
|
+
and services for short-lived data that doesn't need disk persistence.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
_cache_instance: "InMemoryCache | None" = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InMemoryCache:
|
|
17
|
+
"""In-memory cache with TTL support.
|
|
18
|
+
|
|
19
|
+
Provides a Redis-compatible interface for single-process operation.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self):
|
|
23
|
+
self._data: dict[str, Any] = {}
|
|
24
|
+
self._expiry: dict[str, float] = {}
|
|
25
|
+
|
|
26
|
+
def _check_expired(self, key: str) -> bool:
|
|
27
|
+
"""Check if a key has expired and delete it if so."""
|
|
28
|
+
if key in self._expiry:
|
|
29
|
+
if time.time() > self._expiry[key]:
|
|
30
|
+
del self._data[key]
|
|
31
|
+
del self._expiry[key]
|
|
32
|
+
return True
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
def get(self, key: str) -> Any | None:
|
|
36
|
+
"""Get value for key, returns None if not found or expired."""
|
|
37
|
+
if self._check_expired(key):
|
|
38
|
+
return None
|
|
39
|
+
return self._data.get(key)
|
|
40
|
+
|
|
41
|
+
def set(self, key: str, value: Any, ex: int | None = None) -> bool:
|
|
42
|
+
"""Set key to value with optional expiry in seconds."""
|
|
43
|
+
self._data[key] = value
|
|
44
|
+
if ex is not None:
|
|
45
|
+
self._expiry[key] = time.time() + ex
|
|
46
|
+
elif key in self._expiry:
|
|
47
|
+
del self._expiry[key]
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
def setex(self, key: str, time_seconds: int, value: Any) -> bool:
|
|
51
|
+
"""Set key with expiry."""
|
|
52
|
+
return self.set(key, value, ex=time_seconds)
|
|
53
|
+
|
|
54
|
+
def delete(self, *keys: str) -> int:
|
|
55
|
+
"""Delete one or more keys, returns count of deleted keys."""
|
|
56
|
+
count = 0
|
|
57
|
+
for key in keys:
|
|
58
|
+
if key in self._data:
|
|
59
|
+
del self._data[key]
|
|
60
|
+
count += 1
|
|
61
|
+
if key in self._expiry:
|
|
62
|
+
del self._expiry[key]
|
|
63
|
+
return count
|
|
64
|
+
|
|
65
|
+
def setnx(self, key: str, value: Any) -> bool:
|
|
66
|
+
"""Set key only if it doesn't exist. Returns True if set, False otherwise."""
|
|
67
|
+
if self._check_expired(key):
|
|
68
|
+
pass
|
|
69
|
+
elif key in self._data:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
self._data[key] = value
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
def exists(self, *keys: str) -> int:
|
|
76
|
+
"""Check if keys exist, returns count of existing keys."""
|
|
77
|
+
count = 0
|
|
78
|
+
for key in keys:
|
|
79
|
+
if not self._check_expired(key) and key in self._data:
|
|
80
|
+
count += 1
|
|
81
|
+
return count
|
|
82
|
+
|
|
83
|
+
def expire(self, key: str, time_seconds: int) -> bool:
|
|
84
|
+
"""Set expiry on existing key."""
|
|
85
|
+
if key not in self._data or self._check_expired(key):
|
|
86
|
+
return False
|
|
87
|
+
self._expiry[key] = time.time() + time_seconds
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
def ttl(self, key: str) -> int:
|
|
91
|
+
"""Get time to live for key. Returns -1 if no expiry, -2 if not found."""
|
|
92
|
+
if self._check_expired(key) or key not in self._data:
|
|
93
|
+
return -2
|
|
94
|
+
if key not in self._expiry:
|
|
95
|
+
return -1
|
|
96
|
+
remaining = int(self._expiry[key] - time.time())
|
|
97
|
+
return max(0, remaining)
|
|
98
|
+
|
|
99
|
+
def ping(self) -> bool:
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
def close(self) -> None:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_redis() -> InMemoryCache:
|
|
107
|
+
"""Get the shared in-memory cache instance."""
|
|
108
|
+
global _cache_instance
|
|
109
|
+
if _cache_instance is None:
|
|
110
|
+
_cache_instance = InMemoryCache()
|
|
111
|
+
return _cache_instance
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def close_redis() -> None:
|
|
115
|
+
"""Clear the cache (call on shutdown)."""
|
|
116
|
+
global _cache_instance
|
|
117
|
+
if _cache_instance:
|
|
118
|
+
_cache_instance.close()
|
|
119
|
+
_cache_instance = None
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""API routers for Draft."""
|
|
2
|
+
|
|
3
|
+
from app.routers.board import legacy_router as board_legacy_router
|
|
4
|
+
from app.routers.board import router as boards_router
|
|
5
|
+
from app.routers.debug import router as debug_router
|
|
6
|
+
from app.routers.evidence import router as evidence_router
|
|
7
|
+
from app.routers.goals import router as goals_router
|
|
8
|
+
from app.routers.jobs import router as jobs_router
|
|
9
|
+
from app.routers.maintenance import router as maintenance_router
|
|
10
|
+
from app.routers.merge import router as merge_router
|
|
11
|
+
from app.routers.planner import router as planner_router
|
|
12
|
+
from app.routers.repos import router as repos_router
|
|
13
|
+
from app.routers.revisions import router as revisions_router
|
|
14
|
+
from app.routers.tickets import router as tickets_router
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"goals_router",
|
|
18
|
+
"tickets_router",
|
|
19
|
+
"boards_router",
|
|
20
|
+
"board_legacy_router",
|
|
21
|
+
"repos_router",
|
|
22
|
+
"jobs_router",
|
|
23
|
+
"evidence_router",
|
|
24
|
+
"planner_router",
|
|
25
|
+
"revisions_router",
|
|
26
|
+
"merge_router",
|
|
27
|
+
"maintenance_router",
|
|
28
|
+
"debug_router",
|
|
29
|
+
]
|