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,389 @@
|
|
|
1
|
+
"""Service layer for Revision operations."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import select
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
8
|
+
from sqlalchemy.orm import selectinload
|
|
9
|
+
|
|
10
|
+
from app.exceptions import ResourceNotFoundError
|
|
11
|
+
from app.models.evidence import Evidence, EvidenceKind
|
|
12
|
+
from app.models.revision import Revision, RevisionStatus
|
|
13
|
+
from app.models.ticket import Ticket
|
|
14
|
+
from app.services.workspace_service import WorkspaceService
|
|
15
|
+
from app.utils.artifact_reader import read_artifact
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def compute_anchor(file_path: str, hunk_header: str, line_content: str) -> str:
|
|
21
|
+
"""Compute stable anchor using sha1.
|
|
22
|
+
|
|
23
|
+
The anchor is a hash of the file path, hunk header, and line content.
|
|
24
|
+
This allows comments to survive small line shifts between revisions,
|
|
25
|
+
as long as the hunk context and line content remain similar.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
file_path: Path to the file being commented on
|
|
29
|
+
hunk_header: Diff hunk header (e.g., '@@ -10,5 +10,7 @@')
|
|
30
|
+
line_content: Content of the line being commented on
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Truncated sha1 hex digest (16 characters)
|
|
34
|
+
"""
|
|
35
|
+
content = f"{file_path}::{hunk_header}::{line_content}"
|
|
36
|
+
return hashlib.sha1(content.encode()).hexdigest()[:16]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RevisionService:
|
|
40
|
+
"""Service class for Revision business logic."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, db: AsyncSession):
|
|
43
|
+
self.db = db
|
|
44
|
+
|
|
45
|
+
async def create_revision(
|
|
46
|
+
self,
|
|
47
|
+
ticket_id: str,
|
|
48
|
+
job_id: str,
|
|
49
|
+
diff_stat_evidence_id: str | None = None,
|
|
50
|
+
diff_patch_evidence_id: str | None = None,
|
|
51
|
+
) -> Revision:
|
|
52
|
+
"""Create a new revision for a ticket.
|
|
53
|
+
|
|
54
|
+
Automatically:
|
|
55
|
+
- Supersedes any existing open revision for the ticket
|
|
56
|
+
- Increments the revision number
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
ticket_id: The UUID of the ticket
|
|
60
|
+
job_id: The UUID of the job that produced this revision
|
|
61
|
+
diff_stat_evidence_id: Optional evidence ID for git diff stat
|
|
62
|
+
diff_patch_evidence_id: Optional evidence ID for git diff patch
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
The created Revision instance
|
|
66
|
+
"""
|
|
67
|
+
# First supersede any open revisions
|
|
68
|
+
await self.supersede_open_revisions(ticket_id)
|
|
69
|
+
|
|
70
|
+
# Get the next revision number
|
|
71
|
+
next_number = await self._get_next_revision_number(ticket_id)
|
|
72
|
+
|
|
73
|
+
# Create the new revision
|
|
74
|
+
revision = Revision(
|
|
75
|
+
ticket_id=ticket_id,
|
|
76
|
+
job_id=job_id,
|
|
77
|
+
number=next_number,
|
|
78
|
+
status=RevisionStatus.OPEN.value,
|
|
79
|
+
diff_stat_evidence_id=diff_stat_evidence_id,
|
|
80
|
+
diff_patch_evidence_id=diff_patch_evidence_id,
|
|
81
|
+
)
|
|
82
|
+
self.db.add(revision)
|
|
83
|
+
await self.db.flush()
|
|
84
|
+
await self.db.refresh(revision)
|
|
85
|
+
|
|
86
|
+
logger.info(
|
|
87
|
+
f"Created revision {revision.id} (#{next_number}) for ticket {ticket_id}"
|
|
88
|
+
)
|
|
89
|
+
return revision
|
|
90
|
+
|
|
91
|
+
async def _get_next_revision_number(self, ticket_id: str) -> int:
|
|
92
|
+
"""Get the next revision number for a ticket.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
ticket_id: The UUID of the ticket
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
The next revision number (1-based)
|
|
99
|
+
"""
|
|
100
|
+
result = await self.db.execute(
|
|
101
|
+
select(Revision.number)
|
|
102
|
+
.where(Revision.ticket_id == ticket_id)
|
|
103
|
+
.order_by(Revision.number.desc())
|
|
104
|
+
.limit(1)
|
|
105
|
+
)
|
|
106
|
+
last_number = result.scalar_one_or_none()
|
|
107
|
+
return (last_number or 0) + 1
|
|
108
|
+
|
|
109
|
+
async def supersede_open_revisions(self, ticket_id: str) -> int:
|
|
110
|
+
"""Mark all open revisions for a ticket as superseded.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
ticket_id: The UUID of the ticket
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Number of revisions superseded
|
|
117
|
+
"""
|
|
118
|
+
result = await self.db.execute(
|
|
119
|
+
select(Revision).where(
|
|
120
|
+
Revision.ticket_id == ticket_id,
|
|
121
|
+
Revision.status == RevisionStatus.OPEN.value,
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
open_revisions = list(result.scalars().all())
|
|
125
|
+
|
|
126
|
+
for revision in open_revisions:
|
|
127
|
+
revision.status = RevisionStatus.SUPERSEDED.value
|
|
128
|
+
logger.info(f"Superseded revision {revision.id} (#{revision.number})")
|
|
129
|
+
|
|
130
|
+
return len(open_revisions)
|
|
131
|
+
|
|
132
|
+
async def get_revision_by_id(self, revision_id: str) -> Revision:
|
|
133
|
+
"""Get a revision by its ID with all related data.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
revision_id: The UUID of the revision
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
The Revision instance with comments and review_summary loaded
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
ResourceNotFoundError: If the revision is not found
|
|
143
|
+
"""
|
|
144
|
+
result = await self.db.execute(
|
|
145
|
+
select(Revision)
|
|
146
|
+
.where(Revision.id == revision_id)
|
|
147
|
+
.options(
|
|
148
|
+
selectinload(Revision.comments),
|
|
149
|
+
selectinload(Revision.review_summary),
|
|
150
|
+
selectinload(Revision.diff_stat_evidence),
|
|
151
|
+
selectinload(Revision.diff_patch_evidence),
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
revision = result.scalar_one_or_none()
|
|
155
|
+
if revision is None:
|
|
156
|
+
raise ResourceNotFoundError("Revision", revision_id)
|
|
157
|
+
return revision
|
|
158
|
+
|
|
159
|
+
async def get_revisions_for_ticket(self, ticket_id: str) -> list[Revision]:
|
|
160
|
+
"""Get all revisions for a ticket.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
ticket_id: The UUID of the ticket
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
List of Revision instances ordered by number descending
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
ResourceNotFoundError: If the ticket is not found
|
|
170
|
+
"""
|
|
171
|
+
# Verify ticket exists
|
|
172
|
+
result = await self.db.execute(select(Ticket).where(Ticket.id == ticket_id))
|
|
173
|
+
if result.scalar_one_or_none() is None:
|
|
174
|
+
raise ResourceNotFoundError("Ticket", ticket_id)
|
|
175
|
+
|
|
176
|
+
result = await self.db.execute(
|
|
177
|
+
select(Revision)
|
|
178
|
+
.where(Revision.ticket_id == ticket_id)
|
|
179
|
+
.options(selectinload(Revision.comments))
|
|
180
|
+
.order_by(Revision.number.desc())
|
|
181
|
+
)
|
|
182
|
+
return list(result.scalars().all())
|
|
183
|
+
|
|
184
|
+
async def get_latest_revision(self, ticket_id: str) -> Revision | None:
|
|
185
|
+
"""Get the latest (open) revision for a ticket.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
ticket_id: The UUID of the ticket
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
The latest open Revision or None if no open revision exists
|
|
192
|
+
"""
|
|
193
|
+
result = await self.db.execute(
|
|
194
|
+
select(Revision)
|
|
195
|
+
.where(
|
|
196
|
+
Revision.ticket_id == ticket_id,
|
|
197
|
+
Revision.status == RevisionStatus.OPEN.value,
|
|
198
|
+
)
|
|
199
|
+
.options(
|
|
200
|
+
selectinload(Revision.comments),
|
|
201
|
+
selectinload(Revision.review_summary),
|
|
202
|
+
)
|
|
203
|
+
.order_by(Revision.number.desc())
|
|
204
|
+
.limit(1)
|
|
205
|
+
)
|
|
206
|
+
return result.scalar_one_or_none()
|
|
207
|
+
|
|
208
|
+
async def update_revision_status(
|
|
209
|
+
self, revision_id: str, status: RevisionStatus
|
|
210
|
+
) -> Revision:
|
|
211
|
+
"""Update the status of a revision.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
revision_id: The UUID of the revision
|
|
215
|
+
status: The new status
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
The updated Revision instance
|
|
219
|
+
"""
|
|
220
|
+
revision = await self.get_revision_by_id(revision_id)
|
|
221
|
+
revision.status = status.value
|
|
222
|
+
await self.db.flush()
|
|
223
|
+
await self.db.refresh(revision)
|
|
224
|
+
return revision
|
|
225
|
+
|
|
226
|
+
async def _get_repo_root_for_ticket(self, ticket_id: str) -> "Path":
|
|
227
|
+
"""Get the repo root for a ticket by looking up its board's repo_root.
|
|
228
|
+
|
|
229
|
+
Falls back to ConfigService default if board has no repo_root.
|
|
230
|
+
"""
|
|
231
|
+
from pathlib import Path
|
|
232
|
+
|
|
233
|
+
from app.models.board import Board
|
|
234
|
+
|
|
235
|
+
# Get ticket's board
|
|
236
|
+
result = await self.db.execute(
|
|
237
|
+
select(Ticket.board_id).where(Ticket.id == ticket_id)
|
|
238
|
+
)
|
|
239
|
+
board_id = result.scalar_one_or_none()
|
|
240
|
+
|
|
241
|
+
if board_id:
|
|
242
|
+
board_result = await self.db.execute(
|
|
243
|
+
select(Board.repo_root).where(Board.id == board_id)
|
|
244
|
+
)
|
|
245
|
+
repo_root = board_result.scalar_one_or_none()
|
|
246
|
+
if repo_root:
|
|
247
|
+
return Path(repo_root)
|
|
248
|
+
|
|
249
|
+
# Fallback to WorkspaceService
|
|
250
|
+
return WorkspaceService.get_repo_path()
|
|
251
|
+
|
|
252
|
+
async def get_revision_diff(
|
|
253
|
+
self, revision_id: str
|
|
254
|
+
) -> tuple[str | None, str | None]:
|
|
255
|
+
"""Get the diff content for a revision (both stat and patch).
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
revision_id: The UUID of the revision
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Tuple of (diff_stat, diff_patch) content strings
|
|
262
|
+
"""
|
|
263
|
+
revision = await self.get_revision_by_id(revision_id)
|
|
264
|
+
repo_root = await self._get_repo_root_for_ticket(revision.ticket_id)
|
|
265
|
+
|
|
266
|
+
diff_stat = None
|
|
267
|
+
diff_patch = None
|
|
268
|
+
|
|
269
|
+
if revision.diff_stat_evidence:
|
|
270
|
+
diff_stat = await self._read_evidence_content(
|
|
271
|
+
revision.diff_stat_evidence, repo_root
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
if revision.diff_patch_evidence:
|
|
275
|
+
diff_patch = await self._read_evidence_content(
|
|
276
|
+
revision.diff_patch_evidence, repo_root
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
return diff_stat, diff_patch
|
|
280
|
+
|
|
281
|
+
async def get_revision_diff_summary(self, revision_id: str) -> str | None:
|
|
282
|
+
"""Get only the diff stat (summary) for a revision.
|
|
283
|
+
|
|
284
|
+
This is the lightweight call for the file list view.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
revision_id: The UUID of the revision
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
diff_stat content string or None
|
|
291
|
+
"""
|
|
292
|
+
revision = await self.get_revision_by_id(revision_id)
|
|
293
|
+
repo_root = await self._get_repo_root_for_ticket(revision.ticket_id)
|
|
294
|
+
|
|
295
|
+
if revision.diff_stat_evidence:
|
|
296
|
+
return await self._read_evidence_content(
|
|
297
|
+
revision.diff_stat_evidence, repo_root
|
|
298
|
+
)
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
async def get_revision_diff_patch(self, revision_id: str) -> str | None:
|
|
302
|
+
"""Get only the diff patch (heavy content) for a revision.
|
|
303
|
+
|
|
304
|
+
This is the heavyweight call - only fetch when user opens diff viewer.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
revision_id: The UUID of the revision
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
diff_patch content string or None
|
|
311
|
+
"""
|
|
312
|
+
revision = await self.get_revision_by_id(revision_id)
|
|
313
|
+
repo_root = await self._get_repo_root_for_ticket(revision.ticket_id)
|
|
314
|
+
|
|
315
|
+
if revision.diff_patch_evidence:
|
|
316
|
+
return await self._read_evidence_content(
|
|
317
|
+
revision.diff_patch_evidence, repo_root
|
|
318
|
+
)
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
async def _read_evidence_content(
|
|
322
|
+
self, evidence: Evidence, repo_root: "Path | None" = None
|
|
323
|
+
) -> str | None:
|
|
324
|
+
"""Read the content of an evidence file.
|
|
325
|
+
|
|
326
|
+
SECURITY: Uses read_artifact() which enforces:
|
|
327
|
+
- File must be under <repo_root>/.draft
|
|
328
|
+
- No path traversal attacks
|
|
329
|
+
- Size limits
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
evidence: The Evidence instance
|
|
333
|
+
repo_root: The repo root path (if None, falls back to ConfigService default)
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
The content string or None if not readable
|
|
337
|
+
"""
|
|
338
|
+
if not evidence.stdout_path:
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
if repo_root is None:
|
|
343
|
+
repo_root = WorkspaceService.get_repo_path()
|
|
344
|
+
return read_artifact(repo_root, evidence.stdout_path)
|
|
345
|
+
except Exception as e:
|
|
346
|
+
logger.warning(
|
|
347
|
+
f"Failed to read evidence content from {evidence.stdout_path}: {e}"
|
|
348
|
+
)
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
async def find_diff_evidence_for_job(
|
|
352
|
+
self, job_id: str
|
|
353
|
+
) -> tuple[str | None, str | None]:
|
|
354
|
+
"""Find diff stat and patch evidence IDs for a job.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
job_id: The UUID of the job
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
Tuple of (diff_stat_evidence_id, diff_patch_evidence_id)
|
|
361
|
+
"""
|
|
362
|
+
# Find diff stat evidence
|
|
363
|
+
stat_result = await self.db.execute(
|
|
364
|
+
select(Evidence)
|
|
365
|
+
.where(
|
|
366
|
+
Evidence.job_id == job_id,
|
|
367
|
+
Evidence.kind == EvidenceKind.GIT_DIFF_STAT.value,
|
|
368
|
+
)
|
|
369
|
+
.order_by(Evidence.created_at.desc())
|
|
370
|
+
.limit(1)
|
|
371
|
+
)
|
|
372
|
+
stat_evidence = stat_result.scalar_one_or_none()
|
|
373
|
+
|
|
374
|
+
# Find diff patch evidence
|
|
375
|
+
patch_result = await self.db.execute(
|
|
376
|
+
select(Evidence)
|
|
377
|
+
.where(
|
|
378
|
+
Evidence.job_id == job_id,
|
|
379
|
+
Evidence.kind == EvidenceKind.GIT_DIFF_PATCH.value,
|
|
380
|
+
)
|
|
381
|
+
.order_by(Evidence.created_at.desc())
|
|
382
|
+
.limit(1)
|
|
383
|
+
)
|
|
384
|
+
patch_evidence = patch_result.scalar_one_or_none()
|
|
385
|
+
|
|
386
|
+
return (
|
|
387
|
+
stat_evidence.id if stat_evidence else None,
|
|
388
|
+
patch_evidence.id if patch_evidence else None,
|
|
389
|
+
)
|