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,618 @@
1
+ """Canary tests for revision invariants.
2
+
3
+ These tests validate the critical invariants that must hold for the PR-like review system:
4
+ 1. Revision creation is idempotent (same job_id doesn't create duplicate revisions)
5
+ 2. At most one revision can be 'open' per ticket
6
+ 3. Approval is blocked if unresolved comments exist (server-side)
7
+ 4. Feedback bundle includes all unresolved comments
8
+ 5. Orphaned comments are preserved (not dropped)
9
+ """
10
+
11
+ import pytest
12
+ from sqlalchemy import select
13
+ from sqlalchemy.ext.asyncio import AsyncSession
14
+
15
+ from app.models.goal import Goal
16
+ from app.models.job import Job, JobKind, JobStatus
17
+ from app.models.review_comment import AuthorType
18
+ from app.models.review_summary import ReviewDecision
19
+ from app.models.revision import Revision, RevisionStatus
20
+ from app.models.ticket import Ticket
21
+ from app.services.review_service import ReviewService
22
+ from app.services.revision_service import RevisionService
23
+
24
+ # ==================== Test Fixtures ====================
25
+
26
+
27
+ @pytest.fixture
28
+ async def sample_goal(db: AsyncSession) -> Goal:
29
+ """Create a sample goal for testing."""
30
+ goal = Goal(
31
+ title="Test Goal",
32
+ description="Test goal for revision invariants",
33
+ )
34
+ db.add(goal)
35
+ await db.flush()
36
+ await db.refresh(goal)
37
+ return goal
38
+
39
+
40
+ @pytest.fixture
41
+ async def sample_ticket(db: AsyncSession, sample_goal: Goal) -> Ticket:
42
+ """Create a sample ticket for testing."""
43
+ ticket = Ticket(
44
+ title="Test Ticket",
45
+ description="Test ticket for revision invariants",
46
+ state="executing",
47
+ goal_id=sample_goal.id,
48
+ )
49
+ db.add(ticket)
50
+ await db.flush()
51
+ await db.refresh(ticket)
52
+ return ticket
53
+
54
+
55
+ @pytest.fixture
56
+ async def sample_job(db: AsyncSession, sample_ticket: Ticket) -> Job:
57
+ """Create a sample job for testing."""
58
+ job = Job(
59
+ ticket_id=sample_ticket.id,
60
+ kind=JobKind.EXECUTE.value,
61
+ status=JobStatus.SUCCEEDED.value,
62
+ )
63
+ db.add(job)
64
+ await db.flush()
65
+ await db.refresh(job)
66
+ return job
67
+
68
+
69
+ # ==================== Test 1: Revision Idempotency ====================
70
+
71
+
72
+ async def test_revision_idempotency_constraint_prevents_duplicates(
73
+ db: AsyncSession, sample_ticket: Ticket, sample_job: Job
74
+ ):
75
+ """Test that the same job cannot create two revisions.
76
+
77
+ If the same execute job is retried, we must not create Revision N twice.
78
+ The unique constraint on (ticket_id, job_id) should prevent this.
79
+ """
80
+ revision_service = RevisionService(db)
81
+
82
+ # Create first revision
83
+ await revision_service.create_revision(
84
+ ticket_id=sample_ticket.id,
85
+ job_id=sample_job.id,
86
+ )
87
+ await db.commit()
88
+
89
+ # Attempt to create second revision with same job_id
90
+ # This should raise an IntegrityError due to unique constraint
91
+ from sqlalchemy.exc import IntegrityError
92
+
93
+ with pytest.raises(IntegrityError):
94
+ await revision_service.create_revision(
95
+ ticket_id=sample_ticket.id,
96
+ job_id=sample_job.id, # Same job_id!
97
+ )
98
+ await db.commit()
99
+
100
+
101
+ # ==================== Test 2: Two Open Revisions Prevention ====================
102
+
103
+
104
+ async def test_at_most_one_open_revision_per_ticket(
105
+ db: AsyncSession, sample_ticket: Ticket
106
+ ):
107
+ """Test that creating a new revision supersedes the previous open revision.
108
+
109
+ For a ticket:
110
+ - At most 1 revision can be 'open'
111
+ - Creating a new revision must supersede the previous open revision in the same transaction
112
+ """
113
+ revision_service = RevisionService(db)
114
+
115
+ # Create first job and revision
116
+ job1 = Job(
117
+ ticket_id=sample_ticket.id,
118
+ kind=JobKind.EXECUTE.value,
119
+ status=JobStatus.SUCCEEDED.value,
120
+ )
121
+ db.add(job1)
122
+ await db.flush()
123
+
124
+ revision1 = await revision_service.create_revision(
125
+ ticket_id=sample_ticket.id,
126
+ job_id=job1.id,
127
+ )
128
+ await db.commit()
129
+
130
+ # Verify rev1 is open
131
+ await db.refresh(revision1)
132
+ assert revision1.status == RevisionStatus.OPEN.value
133
+
134
+ # Create second job and revision
135
+ job2 = Job(
136
+ ticket_id=sample_ticket.id,
137
+ kind=JobKind.EXECUTE.value,
138
+ status=JobStatus.SUCCEEDED.value,
139
+ )
140
+ db.add(job2)
141
+ await db.flush()
142
+
143
+ revision2 = await revision_service.create_revision(
144
+ ticket_id=sample_ticket.id,
145
+ job_id=job2.id,
146
+ )
147
+ await db.commit()
148
+
149
+ # Refresh rev1 and verify it's superseded
150
+ await db.refresh(revision1)
151
+ assert revision1.status == RevisionStatus.SUPERSEDED.value, (
152
+ "Previous open revision should be superseded"
153
+ )
154
+
155
+ # Verify rev2 is open
156
+ assert revision2.status == RevisionStatus.OPEN.value
157
+
158
+ # Count open revisions - must be exactly 1
159
+ result = await db.execute(
160
+ select(Revision).where(
161
+ Revision.ticket_id == sample_ticket.id,
162
+ Revision.status == RevisionStatus.OPEN.value,
163
+ )
164
+ )
165
+ open_revisions = result.scalars().all()
166
+ assert len(open_revisions) == 1, (
167
+ f"Expected exactly 1 open revision, got {len(open_revisions)}"
168
+ )
169
+ assert open_revisions[0].id == revision2.id
170
+
171
+
172
+ # ==================== Test 3: Approval Gating ====================
173
+
174
+
175
+ async def test_approval_allowed_with_unresolved_comments(
176
+ db: AsyncSession, sample_ticket: Ticket, sample_job: Job
177
+ ):
178
+ """Test that approval succeeds even with unresolved comments.
179
+
180
+ The review service deliberately allows approval with unresolved comments.
181
+ Comments are informational; approving accepts the changes regardless.
182
+ """
183
+ revision_service = RevisionService(db)
184
+ review_service = ReviewService(db)
185
+
186
+ # Create revision
187
+ revision = await revision_service.create_revision(
188
+ ticket_id=sample_ticket.id,
189
+ job_id=sample_job.id,
190
+ )
191
+ await db.commit()
192
+
193
+ # Add an unresolved comment
194
+ await review_service.add_comment(
195
+ revision_id=revision.id,
196
+ file_path="src/example.py",
197
+ line_number=42,
198
+ body="This needs to be fixed",
199
+ author_type=AuthorType.HUMAN,
200
+ )
201
+ await db.commit()
202
+
203
+ # Approval should succeed despite unresolved comments
204
+ summary = await review_service.submit_review(
205
+ revision_id=revision.id,
206
+ decision=ReviewDecision.APPROVED,
207
+ summary="LGTM",
208
+ )
209
+
210
+ assert summary is not None
211
+ assert summary.decision == ReviewDecision.APPROVED.value
212
+
213
+
214
+ async def test_approval_succeeds_when_all_comments_resolved(
215
+ db: AsyncSession, sample_ticket: Ticket, sample_job: Job
216
+ ):
217
+ """Test that approval succeeds when all comments are resolved."""
218
+ revision_service = RevisionService(db)
219
+ review_service = ReviewService(db)
220
+
221
+ # Create revision
222
+ revision = await revision_service.create_revision(
223
+ ticket_id=sample_ticket.id,
224
+ job_id=sample_job.id,
225
+ )
226
+ await db.commit()
227
+
228
+ # Add a comment
229
+ comment = await review_service.add_comment(
230
+ revision_id=revision.id,
231
+ file_path="src/example.py",
232
+ line_number=42,
233
+ body="This needs to be fixed",
234
+ author_type=AuthorType.HUMAN,
235
+ )
236
+ await db.commit()
237
+
238
+ # Resolve the comment
239
+ await review_service.resolve_comment(comment.id)
240
+ await db.commit()
241
+
242
+ # Now approval should succeed
243
+ review_summary = await review_service.submit_review(
244
+ revision_id=revision.id,
245
+ decision=ReviewDecision.APPROVED,
246
+ summary="LGTM",
247
+ )
248
+ await db.commit()
249
+
250
+ assert review_summary.decision == ReviewDecision.APPROVED.value
251
+
252
+
253
+ # ==================== Test 4: Feedback Injection Correctness ====================
254
+
255
+
256
+ async def test_feedback_bundle_contains_unresolved_comments(
257
+ db: AsyncSession, sample_ticket: Ticket, sample_job: Job
258
+ ):
259
+ """Test that feedback bundle includes all unresolved comments.
260
+
261
+ When changes are requested:
262
+ - The feedback bundle must include review summary
263
+ - Only unresolved comments should be included (or resolved with flag)
264
+ """
265
+ revision_service = RevisionService(db)
266
+ review_service = ReviewService(db)
267
+
268
+ # Save IDs upfront before any operations that might expire them
269
+ ticket_id = sample_ticket.id
270
+
271
+ # Create revision
272
+ revision = await revision_service.create_revision(
273
+ ticket_id=ticket_id,
274
+ job_id=sample_job.id,
275
+ )
276
+ revision_id = revision.id
277
+ await db.commit()
278
+
279
+ # Add two comments
280
+ comment1 = await review_service.add_comment(
281
+ revision_id=revision_id,
282
+ file_path="src/example.py",
283
+ line_number=42,
284
+ body="Rename this variable",
285
+ author_type=AuthorType.HUMAN,
286
+ )
287
+ await review_service.add_comment(
288
+ revision_id=revision_id,
289
+ file_path="src/helper.py",
290
+ line_number=10,
291
+ body="Add error handling here",
292
+ author_type=AuthorType.HUMAN,
293
+ )
294
+ await db.commit()
295
+
296
+ # Resolve one comment
297
+ await review_service.resolve_comment(comment1.id)
298
+ await db.commit()
299
+
300
+ # Request changes
301
+ await review_service.submit_review(
302
+ revision_id=revision_id,
303
+ decision=ReviewDecision.CHANGES_REQUESTED,
304
+ summary="Please address the remaining issue",
305
+ )
306
+ await db.commit()
307
+
308
+ # Expire cached objects to force a fresh query
309
+ db.expire_all()
310
+
311
+ # Get feedback bundle
312
+ feedback = await review_service.get_feedback_bundle(revision_id)
313
+
314
+ # Verify feedback bundle structure
315
+ assert feedback.ticket_id == ticket_id
316
+ assert feedback.revision_id == revision_id
317
+ assert feedback.decision == "changes_requested"
318
+ assert feedback.summary == "Please address the remaining issue"
319
+
320
+ # Only unresolved comment should be in the bundle
321
+ assert len(feedback.comments) == 1
322
+ assert feedback.comments[0].file_path == "src/helper.py"
323
+ assert feedback.comments[0].body == "Add error handling here"
324
+
325
+
326
+ # ==================== Test 5: Orphaned Comment Behavior ====================
327
+
328
+
329
+ async def test_orphaned_comments_included_in_feedback_bundle(
330
+ db: AsyncSession, sample_ticket: Ticket, sample_job: Job
331
+ ):
332
+ """Test that comments whose anchors can't be found are still included.
333
+
334
+ When a comment is on a line that's been removed in a new revision:
335
+ - Comment should show as orphaned (via the orphaned flag)
336
+ - Comment should still be included in feedback bundle
337
+ - Comment should NOT be dropped
338
+ """
339
+ revision_service = RevisionService(db)
340
+ review_service = ReviewService(db)
341
+
342
+ # Create revision
343
+ revision = await revision_service.create_revision(
344
+ ticket_id=sample_ticket.id,
345
+ job_id=sample_job.id,
346
+ )
347
+ await db.commit()
348
+
349
+ # Add a comment with specific anchor data
350
+ await review_service.add_comment(
351
+ revision_id=revision.id,
352
+ file_path="src/old_file.py",
353
+ line_number=100, # Line that might not exist after rerun
354
+ body="This function is inefficient",
355
+ author_type=AuthorType.HUMAN,
356
+ hunk_header="@@ -90,10 +90,15 @@",
357
+ line_content="def slow_function():",
358
+ )
359
+ await db.commit()
360
+
361
+ # Request changes
362
+ await review_service.submit_review(
363
+ revision_id=revision.id,
364
+ decision=ReviewDecision.CHANGES_REQUESTED,
365
+ summary="Please optimize",
366
+ )
367
+ await db.commit()
368
+
369
+ # Get feedback bundle - comment should be present
370
+ feedback = await review_service.get_feedback_bundle(revision.id)
371
+
372
+ # The comment must be included (not dropped!)
373
+ assert len(feedback.comments) == 1
374
+ assert feedback.comments[0].file_path == "src/old_file.py"
375
+ assert feedback.comments[0].body == "This function is inefficient"
376
+ # The anchor should be preserved for matching
377
+ assert feedback.comments[0].anchor is not None
378
+
379
+
380
+ # ==================== Test: Auto-rerun Cap ====================
381
+
382
+
383
+ async def test_auto_rerun_cap_enforced(db: AsyncSession, sample_goal: Goal):
384
+ """Test that auto-reruns are capped to prevent infinite loops.
385
+
386
+ Caps:
387
+ - Max 2 auto-reruns PER REVISION (per source_revision_id)
388
+ - Max 5 total revisions per ticket
389
+
390
+ After max reached: require explicit human action.
391
+ """
392
+ # This test validates the logic exists, but the actual cap is enforced
393
+ # in the router endpoint. We test the count logic here.
394
+
395
+ ticket = Ticket(
396
+ title="Rerun Test Ticket",
397
+ description="Test ticket for rerun cap",
398
+ state="needs_human",
399
+ goal_id=sample_goal.id,
400
+ )
401
+ db.add(ticket)
402
+ await db.flush()
403
+
404
+ revision_service = RevisionService(db)
405
+
406
+ # Create first revision (from initial job)
407
+ job1 = Job(
408
+ ticket_id=ticket.id,
409
+ kind=JobKind.EXECUTE.value,
410
+ status=JobStatus.SUCCEEDED.value,
411
+ )
412
+ db.add(job1)
413
+ await db.flush()
414
+
415
+ revision1 = await revision_service.create_revision(
416
+ ticket_id=ticket.id,
417
+ job_id=job1.id,
418
+ )
419
+ await db.commit()
420
+
421
+ # Simulate 2 auto-reruns from revision 1 (max per revision)
422
+ for _i in range(2):
423
+ job = Job(
424
+ ticket_id=ticket.id,
425
+ kind=JobKind.EXECUTE.value,
426
+ status=JobStatus.SUCCEEDED.value,
427
+ source_revision_id=revision1.id, # Addressing revision 1
428
+ )
429
+ db.add(job)
430
+ await db.flush()
431
+
432
+ await revision_service.create_revision(
433
+ ticket_id=ticket.id,
434
+ job_id=job.id,
435
+ )
436
+ await db.commit()
437
+
438
+ # Count jobs that addressed revision 1
439
+ jobs_from_rev1 = await db.execute(
440
+ select(Job).where(Job.source_revision_id == revision1.id)
441
+ )
442
+ reruns_from_rev1 = len(list(jobs_from_rev1.scalars().all()))
443
+
444
+ assert reruns_from_rev1 == 2, "Should have 2 auto-reruns from revision 1"
445
+ # The router logic checks: if reruns_from_this_revision >= 2, reject
446
+ # A 3rd auto-rerun FROM THE SAME REVISION should be blocked
447
+
448
+
449
+ # ==================== Test: Job Source Revision Traceability ====================
450
+
451
+
452
+ async def test_job_source_revision_traceability(
453
+ db: AsyncSession, sample_ticket: Ticket, sample_job: Job
454
+ ):
455
+ """Test that jobs triggered by review have source_revision_id set."""
456
+ revision_service = RevisionService(db)
457
+
458
+ # Create initial revision
459
+ revision = await revision_service.create_revision(
460
+ ticket_id=sample_ticket.id,
461
+ job_id=sample_job.id,
462
+ )
463
+ await db.commit()
464
+
465
+ # Create a new job triggered by review (simulating what the router does)
466
+ new_job = Job(
467
+ ticket_id=sample_ticket.id,
468
+ kind=JobKind.EXECUTE.value,
469
+ status=JobStatus.QUEUED.value,
470
+ source_revision_id=revision.id, # Traceability link
471
+ )
472
+ db.add(new_job)
473
+ await db.commit()
474
+
475
+ # Verify the traceability link
476
+ await db.refresh(new_job)
477
+ assert new_job.source_revision_id == revision.id, (
478
+ "Job should have source_revision_id linking to the revision being addressed"
479
+ )
480
+
481
+
482
+ # ==================== Test: Superseded Revision Guards ====================
483
+
484
+
485
+ async def test_cannot_add_comment_to_superseded_revision(
486
+ db: AsyncSession, sample_goal: Goal
487
+ ):
488
+ """Test that adding comments to superseded revisions is blocked.
489
+
490
+ When a new revision is created, old revisions become superseded.
491
+ Comments on superseded revisions should return 409 Conflict.
492
+ """
493
+ from app.exceptions import ConflictError
494
+
495
+ ticket = Ticket(
496
+ title="Supersede Test Ticket",
497
+ description="Test ticket for supersede guards",
498
+ state="needs_human",
499
+ goal_id=sample_goal.id,
500
+ )
501
+ db.add(ticket)
502
+ await db.flush()
503
+
504
+ revision_service = RevisionService(db)
505
+ review_service = ReviewService(db)
506
+
507
+ # Create first revision
508
+ job1 = Job(
509
+ ticket_id=ticket.id,
510
+ kind=JobKind.EXECUTE.value,
511
+ status=JobStatus.SUCCEEDED.value,
512
+ )
513
+ db.add(job1)
514
+ await db.flush()
515
+
516
+ revision1 = await revision_service.create_revision(
517
+ ticket_id=ticket.id,
518
+ job_id=job1.id,
519
+ )
520
+ revision1_id = revision1.id
521
+ await db.commit()
522
+
523
+ # Create second revision (this supersedes revision1)
524
+ job2 = Job(
525
+ ticket_id=ticket.id,
526
+ kind=JobKind.EXECUTE.value,
527
+ status=JobStatus.SUCCEEDED.value,
528
+ )
529
+ db.add(job2)
530
+ await db.flush()
531
+
532
+ await revision_service.create_revision(
533
+ ticket_id=ticket.id,
534
+ job_id=job2.id,
535
+ )
536
+ await db.commit()
537
+
538
+ # Verify revision1 is now superseded
539
+ db.expire_all()
540
+ result = await db.execute(select(Revision).where(Revision.id == revision1_id))
541
+ revision1_refreshed = result.scalar_one()
542
+ assert revision1_refreshed.status == "superseded"
543
+
544
+ # Attempt to add comment to superseded revision - should fail
545
+ with pytest.raises(ConflictError) as exc_info:
546
+ await review_service.add_comment(
547
+ revision_id=revision1_id,
548
+ file_path="src/example.py",
549
+ line_number=42,
550
+ body="This should fail",
551
+ author_type=AuthorType.HUMAN,
552
+ )
553
+
554
+ assert "superseded" in str(exc_info.value).lower()
555
+
556
+
557
+ async def test_cannot_submit_review_to_superseded_revision(
558
+ db: AsyncSession, sample_goal: Goal
559
+ ):
560
+ """Test that submitting reviews to superseded revisions is blocked.
561
+
562
+ When a new revision is created, old revisions become superseded.
563
+ Reviews on superseded revisions should return 409 Conflict.
564
+ """
565
+ from app.exceptions import ConflictError
566
+
567
+ ticket = Ticket(
568
+ title="Supersede Review Test Ticket",
569
+ description="Test ticket for supersede review guards",
570
+ state="needs_human",
571
+ goal_id=sample_goal.id,
572
+ )
573
+ db.add(ticket)
574
+ await db.flush()
575
+
576
+ revision_service = RevisionService(db)
577
+ review_service = ReviewService(db)
578
+
579
+ # Create first revision
580
+ job1 = Job(
581
+ ticket_id=ticket.id,
582
+ kind=JobKind.EXECUTE.value,
583
+ status=JobStatus.SUCCEEDED.value,
584
+ )
585
+ db.add(job1)
586
+ await db.flush()
587
+
588
+ revision1 = await revision_service.create_revision(
589
+ ticket_id=ticket.id,
590
+ job_id=job1.id,
591
+ )
592
+ revision1_id = revision1.id
593
+ await db.commit()
594
+
595
+ # Create second revision (this supersedes revision1)
596
+ job2 = Job(
597
+ ticket_id=ticket.id,
598
+ kind=JobKind.EXECUTE.value,
599
+ status=JobStatus.SUCCEEDED.value,
600
+ )
601
+ db.add(job2)
602
+ await db.flush()
603
+
604
+ await revision_service.create_revision(
605
+ ticket_id=ticket.id,
606
+ job_id=job2.id,
607
+ )
608
+ await db.commit()
609
+
610
+ # Attempt to submit review to superseded revision - should fail
611
+ with pytest.raises(ConflictError) as exc_info:
612
+ await review_service.submit_review(
613
+ revision_id=revision1_id,
614
+ decision=ReviewDecision.APPROVED,
615
+ summary="This should fail",
616
+ )
617
+
618
+ assert "superseded" in str(exc_info.value).lower()