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,939 @@
1
+ """API router for Revision and Review endpoints."""
2
+
3
+ import logging
4
+ import re
5
+
6
+ from fastapi import APIRouter, Depends, HTTPException, Query, status
7
+
8
+ logger = logging.getLogger(__name__)
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from app.database import get_db
12
+ from app.exceptions import ConflictError, ResourceNotFoundError, ValidationError
13
+ from app.models.job import Job, JobKind, JobStatus
14
+ from app.models.review_comment import AuthorType
15
+ from app.models.review_summary import ReviewDecision
16
+ from app.models.revision import RevisionStatus
17
+ from app.schemas.common import PaginatedResponse
18
+ from app.schemas.review import (
19
+ FeedbackBundle,
20
+ ReviewCommentCreate,
21
+ ReviewCommentListResponse,
22
+ ReviewCommentResponse,
23
+ ReviewSubmit,
24
+ ReviewSummaryResponse,
25
+ )
26
+ from app.schemas.revision import (
27
+ DiffFile,
28
+ DiffPatchResponse,
29
+ DiffSummaryResponse,
30
+ RevisionDetailResponse,
31
+ RevisionDiffResponse,
32
+ RevisionListResponse,
33
+ RevisionResponse,
34
+ RevisionTimelineResponse,
35
+ TimelineEvent,
36
+ )
37
+ from app.services.review_service import ReviewService
38
+ from app.services.revision_service import RevisionService
39
+ from app.services.ticket_service import TicketService
40
+ from app.state_machine import ActorType as TicketActorType
41
+ from app.state_machine import TicketState
42
+
43
+ router = APIRouter(tags=["revisions"])
44
+
45
+
46
+ # ==================== Revision Endpoints ====================
47
+
48
+
49
+ @router.get(
50
+ "/tickets/{ticket_id}/revisions",
51
+ response_model=RevisionListResponse,
52
+ summary="Get all revisions for a ticket",
53
+ )
54
+ async def get_ticket_revisions(
55
+ ticket_id: str,
56
+ db: AsyncSession = Depends(get_db),
57
+ ) -> RevisionListResponse:
58
+ """Get all revisions for a ticket, ordered by revision number descending."""
59
+ service = RevisionService(db)
60
+ try:
61
+ revisions = await service.get_revisions_for_ticket(ticket_id)
62
+ except ResourceNotFoundError as e:
63
+ raise HTTPException(status_code=404, detail=e.message)
64
+
65
+ revision_responses = [
66
+ RevisionResponse(
67
+ id=r.id,
68
+ ticket_id=r.ticket_id,
69
+ job_id=r.job_id,
70
+ number=r.number,
71
+ status=RevisionStatus(r.status),
72
+ diff_stat_evidence_id=r.diff_stat_evidence_id,
73
+ diff_patch_evidence_id=r.diff_patch_evidence_id,
74
+ created_at=r.created_at,
75
+ unresolved_comment_count=r.unresolved_comment_count,
76
+ )
77
+ for r in revisions
78
+ ]
79
+
80
+ return RevisionListResponse(
81
+ revisions=revision_responses,
82
+ total=len(revision_responses),
83
+ )
84
+
85
+
86
+ @router.get(
87
+ "/revisions/{revision_id}",
88
+ response_model=RevisionDetailResponse,
89
+ summary="Get a revision by ID",
90
+ )
91
+ async def get_revision(
92
+ revision_id: str,
93
+ db: AsyncSession = Depends(get_db),
94
+ ) -> RevisionDetailResponse:
95
+ """Get detailed information about a revision including diff content."""
96
+ service = RevisionService(db)
97
+ try:
98
+ revision = await service.get_revision_by_id(revision_id)
99
+ diff_stat, diff_patch = await service.get_revision_diff(revision_id)
100
+ except ResourceNotFoundError as e:
101
+ raise HTTPException(status_code=404, detail=e.message)
102
+
103
+ return RevisionDetailResponse(
104
+ id=revision.id,
105
+ ticket_id=revision.ticket_id,
106
+ job_id=revision.job_id,
107
+ number=revision.number,
108
+ status=RevisionStatus(revision.status),
109
+ diff_stat_evidence_id=revision.diff_stat_evidence_id,
110
+ diff_patch_evidence_id=revision.diff_patch_evidence_id,
111
+ created_at=revision.created_at,
112
+ unresolved_comment_count=revision.unresolved_comment_count,
113
+ diff_stat=diff_stat,
114
+ diff_patch=diff_patch,
115
+ )
116
+
117
+
118
+ @router.get(
119
+ "/revisions/{revision_id}/diff",
120
+ response_model=RevisionDiffResponse,
121
+ summary="Get the diff content for a revision",
122
+ )
123
+ async def get_revision_diff(
124
+ revision_id: str,
125
+ db: AsyncSession = Depends(get_db),
126
+ ) -> RevisionDiffResponse:
127
+ """Get the diff content for a revision with parsed file information."""
128
+ service = RevisionService(db)
129
+ try:
130
+ diff_stat, diff_patch = await service.get_revision_diff(revision_id)
131
+ except ResourceNotFoundError as e:
132
+ raise HTTPException(status_code=404, detail=e.message)
133
+
134
+ # Parse diff stat to extract file information
135
+ files = _parse_diff_stat(diff_stat) if diff_stat else []
136
+
137
+ return RevisionDiffResponse(
138
+ revision_id=revision_id,
139
+ diff_stat=diff_stat,
140
+ diff_patch=diff_patch,
141
+ files=files,
142
+ )
143
+
144
+
145
+ @router.get(
146
+ "/revisions/{revision_id}/diff/summary",
147
+ response_model=DiffSummaryResponse,
148
+ summary="Get lightweight diff summary (stat + file list)",
149
+ )
150
+ async def get_revision_diff_summary(
151
+ revision_id: str,
152
+ db: AsyncSession = Depends(get_db),
153
+ ) -> DiffSummaryResponse:
154
+ """Get the lightweight diff summary for initial UI load.
155
+
156
+ This returns only the diff stat and file list - no heavy patch content.
157
+ Use this for the file tree view. Only fetch /diff/patch when user actually
158
+ opens the diff viewer.
159
+ """
160
+ service = RevisionService(db)
161
+ try:
162
+ diff_stat = await service.get_revision_diff_summary(revision_id)
163
+ except ResourceNotFoundError as e:
164
+ raise HTTPException(status_code=404, detail=e.message)
165
+
166
+ # Parse diff stat to extract file information
167
+ files = _parse_diff_stat(diff_stat) if diff_stat else []
168
+
169
+ return DiffSummaryResponse(
170
+ revision_id=revision_id,
171
+ diff_stat=diff_stat,
172
+ files=files,
173
+ )
174
+
175
+
176
+ @router.get(
177
+ "/revisions/{revision_id}/diff/patch",
178
+ response_model=DiffPatchResponse,
179
+ summary="Get heavyweight diff patch content",
180
+ )
181
+ async def get_revision_diff_patch(
182
+ revision_id: str,
183
+ db: AsyncSession = Depends(get_db),
184
+ ) -> DiffPatchResponse:
185
+ """Get the full diff patch content.
186
+
187
+ This is a heavyweight endpoint - only call when user actually opens
188
+ the diff viewer and wants to see the code changes.
189
+ """
190
+ service = RevisionService(db)
191
+ try:
192
+ diff_patch = await service.get_revision_diff_patch(revision_id)
193
+ except ResourceNotFoundError as e:
194
+ raise HTTPException(status_code=404, detail=e.message)
195
+
196
+ return DiffPatchResponse(
197
+ revision_id=revision_id,
198
+ diff_patch=diff_patch,
199
+ )
200
+
201
+
202
+ def _parse_diff_stat(diff_stat: str) -> list[DiffFile]:
203
+ """Parse git diff --stat output to extract file information.
204
+
205
+ Example input:
206
+ backend/app/models/ticket.py | 10 +++++-----
207
+ backend/app/services/new.py | 50 +++++++++++++++++++++++++++++++++++
208
+ 2 files changed, 55 insertions(+), 5 deletions(-)
209
+ """
210
+ files = []
211
+ # Match lines like: path/to/file | 10 +++++-----
212
+ pattern = r"^\s*(.+?)\s+\|\s+(\d+)\s+(\+*)(\-*)\s*$"
213
+
214
+ for line in diff_stat.split("\n"):
215
+ match = re.match(pattern, line)
216
+ if match:
217
+ path = match.group(1).strip()
218
+ additions = len(match.group(3))
219
+ deletions = len(match.group(4))
220
+
221
+ # Determine status
222
+ if "=>" in path: # renamed
223
+ status = "renamed"
224
+ # Extract old and new paths from "old => new"
225
+ parts = path.split("=>")
226
+ if len(parts) == 2:
227
+ old_path = parts[0].strip().strip("{").strip()
228
+ new_path = parts[1].strip().strip("}").strip()
229
+ files.append(
230
+ DiffFile(
231
+ path=new_path,
232
+ old_path=old_path,
233
+ additions=additions,
234
+ deletions=deletions,
235
+ status=status,
236
+ )
237
+ )
238
+ continue
239
+ elif additions > 0 and deletions == 0:
240
+ # Could be new file, but git diff --stat doesn't clearly indicate
241
+ status = "modified" # Default to modified
242
+ elif deletions > 0 and additions == 0:
243
+ status = "modified"
244
+ else:
245
+ status = "modified"
246
+
247
+ files.append(
248
+ DiffFile(
249
+ path=path,
250
+ additions=additions,
251
+ deletions=deletions,
252
+ status=status,
253
+ )
254
+ )
255
+
256
+ return files
257
+
258
+
259
+ # ==================== Review Comment Endpoints ====================
260
+
261
+
262
+ @router.post(
263
+ "/revisions/{revision_id}/comments",
264
+ response_model=ReviewCommentResponse,
265
+ status_code=status.HTTP_201_CREATED,
266
+ summary="Add an inline comment to a revision",
267
+ )
268
+ async def add_comment(
269
+ revision_id: str,
270
+ data: ReviewCommentCreate,
271
+ db: AsyncSession = Depends(get_db),
272
+ ) -> ReviewCommentResponse:
273
+ """Add an inline comment on a specific line in the revision diff.
274
+
275
+ Returns 409 Conflict if the revision is superseded.
276
+ """
277
+ service = ReviewService(db)
278
+ try:
279
+ comment = await service.add_comment(
280
+ revision_id=revision_id,
281
+ file_path=data.file_path,
282
+ line_number=data.line_number,
283
+ body=data.body,
284
+ author_type=AuthorType(data.author_type.value),
285
+ hunk_header=data.hunk_header,
286
+ line_content=data.line_content,
287
+ )
288
+ await db.commit()
289
+ except ResourceNotFoundError as e:
290
+ raise HTTPException(status_code=404, detail=e.message)
291
+ except ConflictError as e:
292
+ raise HTTPException(status_code=409, detail=e.message)
293
+
294
+ return ReviewCommentResponse(
295
+ id=comment.id,
296
+ revision_id=comment.revision_id,
297
+ file_path=comment.file_path,
298
+ line_number=comment.line_number,
299
+ anchor=comment.anchor,
300
+ body=comment.body,
301
+ author_type=comment.author_type_enum,
302
+ resolved=comment.resolved,
303
+ created_at=comment.created_at,
304
+ line_content=comment.line_content,
305
+ )
306
+
307
+
308
+ @router.get(
309
+ "/revisions/{revision_id}/comments",
310
+ summary="Get all comments for a revision",
311
+ )
312
+ async def get_revision_comments(
313
+ revision_id: str,
314
+ include_resolved: bool = True,
315
+ page: int | None = Query(
316
+ None,
317
+ ge=1,
318
+ description="Page number (1-based). Omit for all results.",
319
+ ),
320
+ limit: int | None = Query(
321
+ None,
322
+ ge=1,
323
+ le=200,
324
+ description="Items per page. Omit for all results.",
325
+ ),
326
+ db: AsyncSession = Depends(get_db),
327
+ ) -> ReviewCommentListResponse | PaginatedResponse[ReviewCommentResponse]:
328
+ """Get all comments for a revision.
329
+
330
+ **Pagination (optional):**
331
+ - If `page` and `limit` are provided, returns paginated response.
332
+ - If omitted, returns all comments (backward compatible).
333
+ """
334
+ service = ReviewService(db)
335
+ try:
336
+ comments = await service.get_comments_for_revision(
337
+ revision_id, include_resolved=include_resolved
338
+ )
339
+ unresolved_count = await service.get_unresolved_count(revision_id)
340
+ except ResourceNotFoundError as e:
341
+ raise HTTPException(status_code=404, detail=e.message)
342
+
343
+ comment_responses = [
344
+ ReviewCommentResponse(
345
+ id=c.id,
346
+ revision_id=c.revision_id,
347
+ file_path=c.file_path,
348
+ line_number=c.line_number,
349
+ anchor=c.anchor,
350
+ body=c.body,
351
+ author_type=c.author_type_enum,
352
+ resolved=c.resolved,
353
+ created_at=c.created_at,
354
+ )
355
+ for c in comments
356
+ ]
357
+
358
+ # If pagination params are provided, return paginated response
359
+ if page is not None and limit is not None:
360
+ total = len(comment_responses)
361
+ offset = (page - 1) * limit
362
+ page_items = comment_responses[offset : offset + limit]
363
+ return PaginatedResponse[ReviewCommentResponse](
364
+ items=page_items,
365
+ total=total,
366
+ page=page,
367
+ limit=limit,
368
+ )
369
+
370
+ # Backward compatible: return all
371
+ return ReviewCommentListResponse(
372
+ comments=comment_responses,
373
+ total=len(comment_responses),
374
+ unresolved_count=unresolved_count,
375
+ )
376
+
377
+
378
+ @router.post(
379
+ "/comments/{comment_id}/resolve",
380
+ response_model=ReviewCommentResponse,
381
+ summary="Resolve a comment",
382
+ )
383
+ async def resolve_comment(
384
+ comment_id: str,
385
+ db: AsyncSession = Depends(get_db),
386
+ ) -> ReviewCommentResponse:
387
+ """Mark a comment as resolved."""
388
+ service = ReviewService(db)
389
+ try:
390
+ comment = await service.resolve_comment(comment_id)
391
+ await db.commit()
392
+ except ResourceNotFoundError as e:
393
+ raise HTTPException(status_code=404, detail=e.message)
394
+
395
+ return ReviewCommentResponse(
396
+ id=comment.id,
397
+ revision_id=comment.revision_id,
398
+ file_path=comment.file_path,
399
+ line_number=comment.line_number,
400
+ anchor=comment.anchor,
401
+ body=comment.body,
402
+ author_type=comment.author_type_enum,
403
+ resolved=comment.resolved,
404
+ created_at=comment.created_at,
405
+ line_content=comment.line_content,
406
+ )
407
+
408
+
409
+ @router.post(
410
+ "/comments/{comment_id}/unresolve",
411
+ response_model=ReviewCommentResponse,
412
+ summary="Unresolve a comment",
413
+ )
414
+ async def unresolve_comment(
415
+ comment_id: str,
416
+ db: AsyncSession = Depends(get_db),
417
+ ) -> ReviewCommentResponse:
418
+ """Mark a comment as unresolved."""
419
+ service = ReviewService(db)
420
+ try:
421
+ comment = await service.unresolve_comment(comment_id)
422
+ await db.commit()
423
+ except ResourceNotFoundError as e:
424
+ raise HTTPException(status_code=404, detail=e.message)
425
+
426
+ return ReviewCommentResponse(
427
+ id=comment.id,
428
+ revision_id=comment.revision_id,
429
+ file_path=comment.file_path,
430
+ line_number=comment.line_number,
431
+ anchor=comment.anchor,
432
+ body=comment.body,
433
+ author_type=comment.author_type_enum,
434
+ resolved=comment.resolved,
435
+ created_at=comment.created_at,
436
+ line_content=comment.line_content,
437
+ )
438
+
439
+
440
+ # ==================== Review Decision Endpoints ====================
441
+
442
+
443
+ @router.post(
444
+ "/revisions/{revision_id}/review",
445
+ response_model=ReviewSummaryResponse,
446
+ status_code=status.HTTP_201_CREATED,
447
+ summary="Submit a review decision for a revision",
448
+ )
449
+ async def submit_review(
450
+ revision_id: str,
451
+ data: ReviewSubmit,
452
+ db: AsyncSession = Depends(get_db),
453
+ ) -> ReviewSummaryResponse:
454
+ """Submit a review decision (approve or request changes) for a revision.
455
+
456
+ If approved:
457
+ - Ticket transitions to 'done'
458
+
459
+ If changes_requested with auto_run_fix=true:
460
+ - Creates a new execute job to address feedback
461
+ - Agent will receive feedback bundle in its prompt
462
+
463
+ Returns 409 Conflict if the revision is superseded.
464
+ """
465
+ review_service = ReviewService(db)
466
+ revision_service = RevisionService(db)
467
+
468
+ # Initialize merge status (will be populated if merge is attempted)
469
+ merge_attempted = False
470
+ merge_success = None
471
+ merge_message = None
472
+
473
+ try:
474
+ # Get revision to find ticket_id
475
+ revision = await revision_service.get_revision_by_id(revision_id)
476
+
477
+ # Submit the review
478
+ review_summary = await review_service.submit_review(
479
+ revision_id=revision_id,
480
+ decision=ReviewDecision(data.decision.value),
481
+ summary=data.summary,
482
+ )
483
+
484
+ # Handle post-review actions
485
+ ticket_service = TicketService(db)
486
+
487
+ if data.decision.value == ReviewDecision.APPROVED.value:
488
+ # Get ticket to check current state
489
+ ticket = await ticket_service.get_ticket_by_id(revision.ticket_id)
490
+
491
+ # Detect target branch from board config or git
492
+ target_branch = "main" # fallback
493
+ board = None
494
+ if ticket.board_id:
495
+ from sqlalchemy import select as sql_select_board
496
+
497
+ from app.models.board import Board
498
+
499
+ board_result = await db.execute(
500
+ sql_select_board(Board).where(Board.id == ticket.board_id)
501
+ )
502
+ board = board_result.scalar_one_or_none()
503
+ if board and board.default_branch:
504
+ target_branch = board.default_branch
505
+
506
+ # CRITICAL: Do NOT transition to DONE yet - it triggers worktree cleanup!
507
+ # We need the worktree to exist for PR creation or merge.
508
+ # Transition happens AFTER merge/PR creation.
509
+
510
+ if data.create_pr:
511
+ # Create a GitHub PR instead of merging directly
512
+ from pathlib import Path
513
+
514
+ from sqlalchemy import select as sql_select
515
+
516
+ from app.models.workspace import Workspace
517
+ from app.services.git_host import get_git_host_provider
518
+
519
+ # Get workspace to find worktree path and branch
520
+ workspace_result = await db.execute(
521
+ sql_select(Workspace).where(
522
+ Workspace.ticket_id == revision.ticket_id
523
+ )
524
+ )
525
+ workspace = workspace_result.scalar_one_or_none()
526
+
527
+ if workspace and workspace.worktree_path:
528
+ try:
529
+ repo_path = Path(workspace.worktree_path)
530
+ git_host = get_git_host_provider(repo_path)
531
+ await git_host.ensure_authenticated()
532
+
533
+ head_branch = workspace.branch_name or f"ticket-{ticket.id[:8]}"
534
+
535
+ pr = await git_host.create_pr(
536
+ repo_path=repo_path,
537
+ title=ticket.title,
538
+ body=(
539
+ f"Implements: {ticket.title}\n\n"
540
+ f"{ticket.description or ''}\n\n"
541
+ f"Ticket ID: {ticket.id}"
542
+ ),
543
+ head_branch=head_branch,
544
+ base_branch=target_branch,
545
+ )
546
+
547
+ # Update ticket with PR information
548
+ from datetime import UTC, datetime
549
+
550
+ ticket.pr_number = pr.number
551
+ ticket.pr_url = pr.url
552
+ ticket.pr_state = pr.state
553
+ ticket.pr_created_at = datetime.now(UTC)
554
+ ticket.pr_head_branch = pr.head_branch
555
+ ticket.pr_base_branch = pr.base_branch
556
+
557
+ logger.info(
558
+ f"Created PR #{pr.number} for ticket {ticket.id}: {pr.url}"
559
+ )
560
+ except Exception as e:
561
+ logger.warning(
562
+ f"Failed to create PR for ticket {ticket.id}: {e}"
563
+ )
564
+ else:
565
+ logger.warning(
566
+ f"No workspace found for ticket {ticket.id}, skipping PR creation"
567
+ )
568
+
569
+ # Transition to DONE after PR creation (worktree will be kept for PR)
570
+ if ticket.state != TicketState.DONE.value:
571
+ await ticket_service.transition_ticket(
572
+ ticket_id=revision.ticket_id,
573
+ to_state=TicketState.DONE,
574
+ actor_type=TicketActorType.HUMAN,
575
+ reason="Revision approved by reviewer",
576
+ auto_verify=False,
577
+ skip_cleanup=True, # Worktree kept for PR
578
+ )
579
+ else:
580
+ # Auto-merge using simple git operations (no state coupling)
581
+ from datetime import UTC, datetime
582
+ from pathlib import Path
583
+
584
+ from sqlalchemy import select as sql_select
585
+
586
+ from app.models.workspace import Workspace
587
+ from app.services.git_merge_simple import (
588
+ GitMergeError,
589
+ cleanup_worktree,
590
+ git_merge_worktree_branch,
591
+ )
592
+ from app.services.workspace_service import WorkspaceService
593
+
594
+ # Track merge status to return to frontend
595
+ merge_attempted = True
596
+ merge_success = False
597
+ merge_message = None
598
+
599
+ try:
600
+ # Get workspace info
601
+ workspace_result = await db.execute(
602
+ sql_select(Workspace).where(
603
+ Workspace.ticket_id == revision.ticket_id
604
+ )
605
+ )
606
+ workspace = workspace_result.scalar_one_or_none()
607
+
608
+ if not workspace or not workspace.is_active:
609
+ merge_message = "No active workspace found for ticket"
610
+ logger.info(
611
+ f"Skipping merge for ticket {revision.ticket_id}: {merge_message}"
612
+ )
613
+ else:
614
+ worktree_path = Path(workspace.worktree_path)
615
+ branch_name = workspace.branch_name
616
+
617
+ # Get repo path
618
+ workspace_service = WorkspaceService(db)
619
+ repo_path = workspace_service.get_repo_path()
620
+
621
+ # Ensure worktree exists
622
+ if not worktree_path.exists():
623
+ merge_message = f"Worktree does not exist: {worktree_path}"
624
+ logger.warning(
625
+ f"Cannot merge ticket {revision.ticket_id}: {merge_message}"
626
+ )
627
+ else:
628
+ # Simple git merge (runs in thread pool to avoid blocking)
629
+ # Read merge configuration with board-level overrides
630
+ from app.services.config_service import ConfigService
631
+
632
+ config_service = ConfigService()
633
+
634
+ # Reuse board fetched earlier for target_branch detection
635
+ board_config = (
636
+ board.config if board and board.config else None
637
+ )
638
+
639
+ # Load config with board overrides applied
640
+ config = config_service.load_config_with_board_overrides(
641
+ board_config=board_config, use_cache=False
642
+ )
643
+ merge_config = config.merge_config
644
+
645
+ import asyncio
646
+
647
+ merge_result = await asyncio.to_thread(
648
+ git_merge_worktree_branch,
649
+ repo_path=repo_path,
650
+ branch_name=branch_name,
651
+ target_branch=target_branch,
652
+ delete_branch_after=merge_config.delete_branch_after_merge,
653
+ push_to_remote=merge_config.push_after_merge,
654
+ squash=merge_config.squash_merge,
655
+ check_divergence=merge_config.check_divergence,
656
+ )
657
+
658
+ merge_success = merge_result.success
659
+ merge_message = merge_result.message
660
+ logger.info(
661
+ f"Merge result for ticket {revision.ticket_id}: {merge_message}"
662
+ )
663
+
664
+ # Record merge event so merge-status correctly reports is_merged
665
+ if merge_success:
666
+ import json as _json
667
+
668
+ from app.models.ticket_event import TicketEvent
669
+ from app.state_machine import (
670
+ TicketState as SM_TicketState,
671
+ )
672
+
673
+ merge_event = TicketEvent(
674
+ ticket_id=revision.ticket_id,
675
+ event_type="merge_succeeded",
676
+ from_state=SM_TicketState.DONE.value,
677
+ to_state=SM_TicketState.DONE.value,
678
+ actor_type="system",
679
+ actor_id="review_auto_merge",
680
+ reason=f"Auto-merged on approval: {merge_message}",
681
+ payload_json=_json.dumps(
682
+ {
683
+ "strategy": "merge",
684
+ "worktree_branch": branch_name,
685
+ "base_branch": target_branch,
686
+ "auto_merge": True,
687
+ }
688
+ ),
689
+ )
690
+ db.add(merge_event)
691
+
692
+ # Cleanup worktree
693
+ if merge_success:
694
+ cleanup_success = await asyncio.to_thread(
695
+ cleanup_worktree,
696
+ repo_path=repo_path,
697
+ worktree_path=worktree_path,
698
+ )
699
+ if cleanup_success:
700
+ # Mark workspace as cleaned up
701
+ workspace.cleaned_up_at = datetime.now(UTC)
702
+ logger.info(
703
+ f"Cleaned up worktree for ticket {revision.ticket_id}"
704
+ )
705
+
706
+ except GitMergeError as e:
707
+ merge_message = f"Git merge failed: {str(e)}"
708
+ logger.error(
709
+ f"Merge error for ticket {revision.ticket_id}: {merge_message}"
710
+ )
711
+ except Exception as e:
712
+ merge_message = f"Unexpected merge error: {str(e)}"
713
+ logger.error(
714
+ f"Unexpected error during merge for ticket {revision.ticket_id}: {e}",
715
+ exc_info=True,
716
+ )
717
+
718
+ # Transition to DONE after merge attempt (even if merge failed - review was approved)
719
+ if ticket.state != TicketState.DONE.value:
720
+ await ticket_service.transition_ticket(
721
+ ticket_id=revision.ticket_id,
722
+ to_state=TicketState.DONE,
723
+ actor_type=TicketActorType.HUMAN,
724
+ reason="Revision approved by reviewer",
725
+ auto_verify=False,
726
+ skip_cleanup=True, # Merge path handles cleanup above
727
+ )
728
+ elif (
729
+ data.decision.value == ReviewDecision.CHANGES_REQUESTED.value
730
+ and data.auto_run_fix
731
+ ):
732
+ # Auto-rerun caps to prevent infinite loops:
733
+ # - Max 2 auto-reruns per revision (per source_revision_id)
734
+ # - Max 5 total revisions per ticket overall
735
+ MAX_AUTO_RERUNS_PER_REVISION = 2
736
+ MAX_REVISIONS_PER_TICKET = 5
737
+
738
+ # Check per-revision rerun cap (how many times THIS revision has been addressed)
739
+ from sqlalchemy import select as sql_select
740
+
741
+ rerun_result = await db.execute(
742
+ sql_select(Job).where(Job.source_revision_id == revision_id)
743
+ )
744
+ reruns_from_this_revision = len(list(rerun_result.scalars().all()))
745
+
746
+ if reruns_from_this_revision >= MAX_AUTO_RERUNS_PER_REVISION:
747
+ raise ValidationError(
748
+ f"Maximum auto-reruns ({MAX_AUTO_RERUNS_PER_REVISION}) from this revision reached. "
749
+ "Please manually create an execute job or resolve the feedback differently."
750
+ )
751
+
752
+ # Check per-ticket total revisions cap
753
+ revisions_list = await revision_service.get_revisions_for_ticket(
754
+ revision.ticket_id
755
+ )
756
+ if len(revisions_list) >= MAX_REVISIONS_PER_TICKET:
757
+ raise ValidationError(
758
+ f"Maximum total revisions ({MAX_REVISIONS_PER_TICKET}) for this ticket reached. "
759
+ "Consider creating a new ticket for remaining work."
760
+ )
761
+
762
+ # Transition to executing and create new execute job
763
+ await ticket_service.transition_ticket(
764
+ ticket_id=revision.ticket_id,
765
+ to_state=TicketState.EXECUTING,
766
+ actor_type=TicketActorType.HUMAN,
767
+ reason="Changes requested - triggering agent re-execution",
768
+ auto_verify=False,
769
+ )
770
+
771
+ # Create new execute job with source_revision_id for traceability
772
+ from app.services.task_dispatch import enqueue_task
773
+
774
+ job = Job(
775
+ ticket_id=revision.ticket_id,
776
+ kind=JobKind.EXECUTE.value,
777
+ status=JobStatus.QUEUED.value,
778
+ source_revision_id=revision_id, # Track which revision is being addressed
779
+ )
780
+ db.add(job)
781
+ await db.flush()
782
+ await db.refresh(job)
783
+
784
+ # Commit BEFORE enqueue_task to release the SQLite write lock.
785
+ # enqueue_task opens a separate sqlite3 connection which would
786
+ # deadlock if this session still holds the write lock.
787
+ await db.commit()
788
+
789
+ # Enqueue the execute task (outside write lock)
790
+ task = enqueue_task("execute_ticket", args=[job.id])
791
+ job.celery_task_id = task.id
792
+ await db.commit()
793
+
794
+ except ResourceNotFoundError as e:
795
+ raise HTTPException(status_code=404, detail=e.message)
796
+ except ConflictError as e:
797
+ raise HTTPException(status_code=409, detail=e.message)
798
+ except ValidationError as e:
799
+ raise HTTPException(status_code=400, detail=e.message)
800
+
801
+ return ReviewSummaryResponse(
802
+ id=review_summary.id,
803
+ revision_id=review_summary.revision_id,
804
+ decision=review_summary.decision_enum,
805
+ body=review_summary.body,
806
+ created_at=review_summary.created_at,
807
+ merge_attempted=merge_attempted,
808
+ merge_success=merge_success,
809
+ merge_message=merge_message,
810
+ )
811
+
812
+
813
+ @router.get(
814
+ "/revisions/{revision_id}/feedback-bundle",
815
+ response_model=FeedbackBundle,
816
+ summary="Get the feedback bundle for a revision",
817
+ )
818
+ async def get_feedback_bundle(
819
+ revision_id: str,
820
+ db: AsyncSession = Depends(get_db),
821
+ ) -> FeedbackBundle:
822
+ """Get the structured feedback bundle for a revision.
823
+
824
+ This is the feedback that gets injected into the agent prompt
825
+ when creating a new revision after changes are requested.
826
+ """
827
+ service = ReviewService(db)
828
+ try:
829
+ return await service.get_feedback_bundle(revision_id)
830
+ except ResourceNotFoundError as e:
831
+ raise HTTPException(status_code=404, detail=e.message)
832
+
833
+
834
+ @router.get(
835
+ "/revisions/{revision_id}/timeline",
836
+ response_model=RevisionTimelineResponse,
837
+ summary="Get the review timeline for a revision",
838
+ )
839
+ async def get_revision_timeline(
840
+ revision_id: str,
841
+ db: AsyncSession = Depends(get_db),
842
+ ) -> RevisionTimelineResponse:
843
+ """Get the timeline of events for a revision.
844
+
845
+ Shows a chronological feed of:
846
+ - Revision created
847
+ - Comments added
848
+ - Review submitted
849
+ - Jobs queued/completed
850
+ """
851
+ from sqlalchemy import select as sql_select
852
+ from sqlalchemy.orm import selectinload
853
+
854
+ from app.models.revision import Revision
855
+
856
+ # Get revision with all related data
857
+ result = await db.execute(
858
+ sql_select(Revision)
859
+ .where(Revision.id == revision_id)
860
+ .options(
861
+ selectinload(Revision.comments),
862
+ selectinload(Revision.review_summary),
863
+ selectinload(Revision.job),
864
+ )
865
+ )
866
+ revision = result.scalar_one_or_none()
867
+ if revision is None:
868
+ raise HTTPException(status_code=404, detail=f"Revision {revision_id} not found")
869
+
870
+ events: list[TimelineEvent] = []
871
+
872
+ # Event 1: Revision created
873
+ events.append(
874
+ TimelineEvent(
875
+ id=f"rev-{revision.id}",
876
+ event_type="revision_created",
877
+ actor="agent",
878
+ message=f"Revision {revision.number} created by executor",
879
+ created_at=revision.created_at,
880
+ metadata={"revision_number": revision.number, "job_id": revision.job_id},
881
+ )
882
+ )
883
+
884
+ # Event 2: Comments added
885
+ for comment in revision.comments:
886
+ events.append(
887
+ TimelineEvent(
888
+ id=f"comment-{comment.id}",
889
+ event_type="comment_added",
890
+ actor=comment.author_type,
891
+ message=f"Comment on {comment.file_path}:{comment.line_number}",
892
+ created_at=comment.created_at,
893
+ metadata={
894
+ "file_path": comment.file_path,
895
+ "line_number": comment.line_number,
896
+ "resolved": comment.resolved,
897
+ },
898
+ )
899
+ )
900
+
901
+ # Event 3: Review submitted
902
+ if revision.review_summary:
903
+ events.append(
904
+ TimelineEvent(
905
+ id=f"review-{revision.review_summary.id}",
906
+ event_type="review_submitted",
907
+ actor="human",
908
+ message=f"Review: {revision.review_summary.decision}",
909
+ created_at=revision.review_summary.created_at,
910
+ metadata={"decision": revision.review_summary.decision},
911
+ )
912
+ )
913
+
914
+ # Event 4: Follow-up jobs (jobs with source_revision_id = this revision)
915
+ followup_result = await db.execute(
916
+ sql_select(Job).where(Job.source_revision_id == revision_id)
917
+ )
918
+ followup_jobs = list(followup_result.scalars().all())
919
+ for job in followup_jobs:
920
+ events.append(
921
+ TimelineEvent(
922
+ id=f"job-{job.id}",
923
+ event_type="job_queued" if job.status == "queued" else "job_completed",
924
+ actor="system",
925
+ message=f"Auto rerun queued (job {job.id[:8]}...)"
926
+ if job.status == "queued"
927
+ else f"Auto rerun {job.status}",
928
+ created_at=job.created_at,
929
+ metadata={"job_id": job.id, "job_status": job.status},
930
+ )
931
+ )
932
+
933
+ # Sort events by created_at
934
+ events.sort(key=lambda e: e.created_at)
935
+
936
+ return RevisionTimelineResponse(
937
+ revision_id=revision_id,
938
+ events=events,
939
+ )