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.
Files changed (250) hide show
  1. package/app/backend/.env.example +9 -0
  2. package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_patch.txt +195 -0
  3. package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_stat.txt +6 -0
  4. package/app/backend/CURL_EXAMPLES.md +335 -0
  5. package/app/backend/ENV_SETUP.md +65 -0
  6. package/app/backend/alembic/env.py +71 -0
  7. package/app/backend/alembic/script.py.mako +28 -0
  8. package/app/backend/alembic/versions/001_initial_schema.py +104 -0
  9. package/app/backend/alembic/versions/002_add_jobs_table.py +52 -0
  10. package/app/backend/alembic/versions/003_add_workspace_table.py +48 -0
  11. package/app/backend/alembic/versions/004_add_evidence_table.py +56 -0
  12. package/app/backend/alembic/versions/005_add_verification_commands.py +32 -0
  13. package/app/backend/alembic/versions/006_add_planner_lock_table.py +39 -0
  14. package/app/backend/alembic/versions/007_add_revision_review_tables.py +126 -0
  15. package/app/backend/alembic/versions/008_add_revision_idempotency_and_traceability.py +52 -0
  16. package/app/backend/alembic/versions/009_add_job_health_fields.py +46 -0
  17. package/app/backend/alembic/versions/010_add_review_comment_line_content.py +36 -0
  18. package/app/backend/alembic/versions/011_add_analysis_cache.py +47 -0
  19. package/app/backend/alembic/versions/012_add_boards_table.py +102 -0
  20. package/app/backend/alembic/versions/013_add_ticket_blocking.py +45 -0
  21. package/app/backend/alembic/versions/014_add_agent_sessions.py +220 -0
  22. package/app/backend/alembic/versions/015_add_ticket_sort_order.py +33 -0
  23. package/app/backend/alembic/versions/03220f0b93ae_add_pr_fields_to_ticket.py +49 -0
  24. package/app/backend/alembic/versions/0c2d89fff3b1_seed_board_configs_from_yaml.py +206 -0
  25. package/app/backend/alembic/versions/3348e5cf54c1_add_merge_checklist_table.py +67 -0
  26. package/app/backend/alembic/versions/357c780ee445_add_goal_status.py +34 -0
  27. package/app/backend/alembic/versions/553340b7e26c_add_autonomy_fields_to_goal.py +65 -0
  28. package/app/backend/alembic/versions/774dc335c679_merge_migration_heads.py +23 -0
  29. package/app/backend/alembic/versions/7b307e847cbd_merge_heads.py +23 -0
  30. package/app/backend/alembic/versions/82ecd978cc70_add_missing_indexes.py +48 -0
  31. package/app/backend/alembic/versions/8ef5054dc280_add_normalized_log_entries.py +173 -0
  32. package/app/backend/alembic/versions/8f3e2bd8ea3b_merge_migration_heads.py +23 -0
  33. package/app/backend/alembic/versions/9d17f0698d3b_add_config_column_to_boards_table.py +30 -0
  34. package/app/backend/alembic/versions/add_agent_conversation_history.py +72 -0
  35. package/app/backend/alembic/versions/add_job_variant.py +34 -0
  36. package/app/backend/alembic/versions/add_performance_indexes.py +95 -0
  37. package/app/backend/alembic/versions/add_repos_and_board_repos.py +174 -0
  38. package/app/backend/alembic/versions/add_session_id_to_jobs.py +27 -0
  39. package/app/backend/alembic/versions/add_sqlite_backend_tables.py +104 -0
  40. package/app/backend/alembic/versions/b10fb0b62240_add_diff_content_to_revisions.py +34 -0
  41. package/app/backend/alembic.ini +89 -0
  42. package/app/backend/app/__init__.py +3 -0
  43. package/app/backend/app/data_dir.py +85 -0
  44. package/app/backend/app/database.py +70 -0
  45. package/app/backend/app/database_sync.py +64 -0
  46. package/app/backend/app/dependencies/__init__.py +5 -0
  47. package/app/backend/app/dependencies/auth.py +80 -0
  48. package/app/backend/app/dependencies.py +43 -0
  49. package/app/backend/app/exceptions.py +178 -0
  50. package/app/backend/app/executors/__init__.py +1 -0
  51. package/app/backend/app/executors/adapters/__init__.py +1 -0
  52. package/app/backend/app/executors/adapters/aider.py +152 -0
  53. package/app/backend/app/executors/adapters/amazon_q.py +103 -0
  54. package/app/backend/app/executors/adapters/amp.py +123 -0
  55. package/app/backend/app/executors/adapters/claude.py +177 -0
  56. package/app/backend/app/executors/adapters/cline.py +127 -0
  57. package/app/backend/app/executors/adapters/codex.py +167 -0
  58. package/app/backend/app/executors/adapters/copilot.py +202 -0
  59. package/app/backend/app/executors/adapters/cursor.py +87 -0
  60. package/app/backend/app/executors/adapters/droid.py +123 -0
  61. package/app/backend/app/executors/adapters/gemini.py +132 -0
  62. package/app/backend/app/executors/adapters/goose.py +131 -0
  63. package/app/backend/app/executors/adapters/opencode.py +123 -0
  64. package/app/backend/app/executors/adapters/qwen.py +123 -0
  65. package/app/backend/app/executors/plugins/__init__.py +1 -0
  66. package/app/backend/app/executors/registry.py +202 -0
  67. package/app/backend/app/executors/spec.py +226 -0
  68. package/app/backend/app/main.py +486 -0
  69. package/app/backend/app/middleware/__init__.py +13 -0
  70. package/app/backend/app/middleware/idempotency.py +426 -0
  71. package/app/backend/app/middleware/rate_limit.py +312 -0
  72. package/app/backend/app/middleware/security_headers.py +43 -0
  73. package/app/backend/app/middleware/timeout.py +37 -0
  74. package/app/backend/app/models/__init__.py +56 -0
  75. package/app/backend/app/models/agent_conversation_history.py +56 -0
  76. package/app/backend/app/models/agent_session.py +127 -0
  77. package/app/backend/app/models/analysis_cache.py +49 -0
  78. package/app/backend/app/models/base.py +9 -0
  79. package/app/backend/app/models/board.py +79 -0
  80. package/app/backend/app/models/board_repo.py +68 -0
  81. package/app/backend/app/models/cost_budget.py +42 -0
  82. package/app/backend/app/models/enums.py +40 -0
  83. package/app/backend/app/models/evidence.py +132 -0
  84. package/app/backend/app/models/goal.py +102 -0
  85. package/app/backend/app/models/idempotency_entry.py +30 -0
  86. package/app/backend/app/models/job.py +163 -0
  87. package/app/backend/app/models/job_queue.py +39 -0
  88. package/app/backend/app/models/kv_store.py +28 -0
  89. package/app/backend/app/models/merge_checklist.py +87 -0
  90. package/app/backend/app/models/normalized_log.py +100 -0
  91. package/app/backend/app/models/planner_lock.py +43 -0
  92. package/app/backend/app/models/rate_limit_entry.py +25 -0
  93. package/app/backend/app/models/repo.py +66 -0
  94. package/app/backend/app/models/review_comment.py +91 -0
  95. package/app/backend/app/models/review_summary.py +69 -0
  96. package/app/backend/app/models/revision.py +130 -0
  97. package/app/backend/app/models/ticket.py +223 -0
  98. package/app/backend/app/models/ticket_event.py +83 -0
  99. package/app/backend/app/models/user.py +47 -0
  100. package/app/backend/app/models/workspace.py +71 -0
  101. package/app/backend/app/redis_client.py +119 -0
  102. package/app/backend/app/routers/__init__.py +29 -0
  103. package/app/backend/app/routers/agents.py +296 -0
  104. package/app/backend/app/routers/auth.py +94 -0
  105. package/app/backend/app/routers/board.py +885 -0
  106. package/app/backend/app/routers/dashboard.py +351 -0
  107. package/app/backend/app/routers/debug.py +528 -0
  108. package/app/backend/app/routers/evidence.py +96 -0
  109. package/app/backend/app/routers/executors.py +324 -0
  110. package/app/backend/app/routers/goals.py +574 -0
  111. package/app/backend/app/routers/jobs.py +448 -0
  112. package/app/backend/app/routers/maintenance.py +172 -0
  113. package/app/backend/app/routers/merge.py +360 -0
  114. package/app/backend/app/routers/planner.py +537 -0
  115. package/app/backend/app/routers/pull_requests.py +382 -0
  116. package/app/backend/app/routers/repos.py +263 -0
  117. package/app/backend/app/routers/revisions.py +939 -0
  118. package/app/backend/app/routers/settings.py +267 -0
  119. package/app/backend/app/routers/tickets.py +2003 -0
  120. package/app/backend/app/routers/webhooks.py +143 -0
  121. package/app/backend/app/routers/websocket.py +249 -0
  122. package/app/backend/app/schemas/__init__.py +109 -0
  123. package/app/backend/app/schemas/board.py +87 -0
  124. package/app/backend/app/schemas/common.py +33 -0
  125. package/app/backend/app/schemas/evidence.py +87 -0
  126. package/app/backend/app/schemas/goal.py +90 -0
  127. package/app/backend/app/schemas/job.py +97 -0
  128. package/app/backend/app/schemas/merge.py +139 -0
  129. package/app/backend/app/schemas/planner.py +500 -0
  130. package/app/backend/app/schemas/repo.py +187 -0
  131. package/app/backend/app/schemas/review.py +137 -0
  132. package/app/backend/app/schemas/revision.py +114 -0
  133. package/app/backend/app/schemas/ticket.py +238 -0
  134. package/app/backend/app/schemas/ticket_event.py +72 -0
  135. package/app/backend/app/schemas/workspace.py +19 -0
  136. package/app/backend/app/services/__init__.py +31 -0
  137. package/app/backend/app/services/agent_memory_service.py +223 -0
  138. package/app/backend/app/services/agent_registry.py +346 -0
  139. package/app/backend/app/services/agent_session_manager.py +318 -0
  140. package/app/backend/app/services/agent_session_service.py +219 -0
  141. package/app/backend/app/services/agent_tools.py +379 -0
  142. package/app/backend/app/services/auth_service.py +98 -0
  143. package/app/backend/app/services/autonomy_service.py +380 -0
  144. package/app/backend/app/services/board_repo_service.py +201 -0
  145. package/app/backend/app/services/board_service.py +326 -0
  146. package/app/backend/app/services/cleanup_service.py +1085 -0
  147. package/app/backend/app/services/config_service.py +908 -0
  148. package/app/backend/app/services/context_gatherer.py +557 -0
  149. package/app/backend/app/services/cost_tracking_service.py +293 -0
  150. package/app/backend/app/services/cursor_log_normalizer.py +536 -0
  151. package/app/backend/app/services/delivery_pipeline.py +440 -0
  152. package/app/backend/app/services/executor_service.py +634 -0
  153. package/app/backend/app/services/git_host/__init__.py +11 -0
  154. package/app/backend/app/services/git_host/factory.py +87 -0
  155. package/app/backend/app/services/git_host/github.py +270 -0
  156. package/app/backend/app/services/git_host/gitlab.py +194 -0
  157. package/app/backend/app/services/git_host/protocol.py +75 -0
  158. package/app/backend/app/services/git_merge_simple.py +346 -0
  159. package/app/backend/app/services/git_ops.py +384 -0
  160. package/app/backend/app/services/github_service.py +233 -0
  161. package/app/backend/app/services/goal_service.py +113 -0
  162. package/app/backend/app/services/job_service.py +423 -0
  163. package/app/backend/app/services/job_watchdog_service.py +424 -0
  164. package/app/backend/app/services/langchain_adapter.py +122 -0
  165. package/app/backend/app/services/llm_provider_clients.py +351 -0
  166. package/app/backend/app/services/llm_service.py +285 -0
  167. package/app/backend/app/services/log_normalizer.py +342 -0
  168. package/app/backend/app/services/log_stream_service.py +276 -0
  169. package/app/backend/app/services/merge_checklist_service.py +264 -0
  170. package/app/backend/app/services/merge_service.py +784 -0
  171. package/app/backend/app/services/orchestrator_log.py +84 -0
  172. package/app/backend/app/services/planner_service.py +1662 -0
  173. package/app/backend/app/services/planner_tick_sync.py +1040 -0
  174. package/app/backend/app/services/queued_message_service.py +156 -0
  175. package/app/backend/app/services/reliability_wrapper.py +389 -0
  176. package/app/backend/app/services/repo_discovery_service.py +318 -0
  177. package/app/backend/app/services/review_service.py +334 -0
  178. package/app/backend/app/services/revision_service.py +389 -0
  179. package/app/backend/app/services/safe_autopilot.py +510 -0
  180. package/app/backend/app/services/sqlite_worker.py +372 -0
  181. package/app/backend/app/services/task_dispatch.py +135 -0
  182. package/app/backend/app/services/ticket_generation_service.py +1781 -0
  183. package/app/backend/app/services/ticket_service.py +486 -0
  184. package/app/backend/app/services/udar_planner_service.py +1007 -0
  185. package/app/backend/app/services/webhook_service.py +126 -0
  186. package/app/backend/app/services/workspace_service.py +465 -0
  187. package/app/backend/app/services/worktree_file_service.py +92 -0
  188. package/app/backend/app/services/worktree_validator.py +213 -0
  189. package/app/backend/app/sqlite_kv.py +278 -0
  190. package/app/backend/app/state_machine.py +128 -0
  191. package/app/backend/app/templates/__init__.py +5 -0
  192. package/app/backend/app/templates/registry.py +243 -0
  193. package/app/backend/app/utils/__init__.py +5 -0
  194. package/app/backend/app/utils/artifact_reader.py +87 -0
  195. package/app/backend/app/utils/circuit_breaker.py +229 -0
  196. package/app/backend/app/utils/db_retry.py +136 -0
  197. package/app/backend/app/utils/ignored_fields.py +123 -0
  198. package/app/backend/app/utils/validators.py +54 -0
  199. package/app/backend/app/websocket/__init__.py +5 -0
  200. package/app/backend/app/websocket/manager.py +179 -0
  201. package/app/backend/app/websocket/state_tracker.py +113 -0
  202. package/app/backend/app/worker.py +3190 -0
  203. package/app/backend/calculator_tickets.json +40 -0
  204. package/app/backend/canary_tests.sh +591 -0
  205. package/app/backend/celerybeat-schedule +0 -0
  206. package/app/backend/celerybeat-schedule-shm +0 -0
  207. package/app/backend/celerybeat-schedule-wal +0 -0
  208. package/app/backend/logs/.gitkeep +3 -0
  209. package/app/backend/multiplication_division_implementation_tickets.json +55 -0
  210. package/app/backend/multiplication_division_tickets.json +42 -0
  211. package/app/backend/pyproject.toml +45 -0
  212. package/app/backend/requirements-dev.txt +8 -0
  213. package/app/backend/requirements.txt +20 -0
  214. package/app/backend/run.sh +30 -0
  215. package/app/backend/run_with_logs.sh +10 -0
  216. package/app/backend/scientific_calculator_tickets.json +40 -0
  217. package/app/backend/scripts/extract_openapi.py +21 -0
  218. package/app/backend/scripts/seed_demo.py +187 -0
  219. package/app/backend/setup_demo_review.py +302 -0
  220. package/app/backend/test_actual_parse.py +41 -0
  221. package/app/backend/test_agent_streaming.py +61 -0
  222. package/app/backend/test_parse.py +51 -0
  223. package/app/backend/test_streaming.py +51 -0
  224. package/app/backend/test_subprocess_streaming.py +50 -0
  225. package/app/backend/tests/__init__.py +1 -0
  226. package/app/backend/tests/conftest.py +46 -0
  227. package/app/backend/tests/test_auth.py +341 -0
  228. package/app/backend/tests/test_autonomy_service.py +391 -0
  229. package/app/backend/tests/test_cleanup_service_safety.py +417 -0
  230. package/app/backend/tests/test_middleware.py +279 -0
  231. package/app/backend/tests/test_planner_providers.py +290 -0
  232. package/app/backend/tests/test_planner_unblock.py +183 -0
  233. package/app/backend/tests/test_revision_invariants.py +618 -0
  234. package/app/backend/tests/test_sqlite_kv.py +290 -0
  235. package/app/backend/tests/test_sqlite_worker.py +353 -0
  236. package/app/backend/tests/test_task_dispatch.py +100 -0
  237. package/app/backend/tests/test_ticket_validation.py +304 -0
  238. package/app/backend/tests/test_udar_agent.py +693 -0
  239. package/app/backend/tests/test_webhook_service.py +184 -0
  240. package/app/backend/tickets_output.json +59 -0
  241. package/app/backend/user_management_tickets.json +50 -0
  242. package/app/backend/uvicorn.log +0 -0
  243. package/app/draft.yaml +313 -0
  244. package/app/frontend/dist/assets/index-LcjCczu5.js +155 -0
  245. package/app/frontend/dist/assets/index-_FP_279e.css +1 -0
  246. package/app/frontend/dist/index.html +14 -0
  247. package/app/frontend/dist/vite.svg +1 -0
  248. package/app/frontend/package.json +101 -0
  249. package/bin/cli.js +527 -0
  250. 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
+ )