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,380 @@
1
+ """Service for full autonomy mode safety checks and auto-actions.
2
+
3
+ Autonomy mode allows goals to bypass manual gates (ticket approval,
4
+ revision approval, merge, follow-up approval) with configurable safety rails.
5
+
6
+ All auto-actions are recorded as TicketEvents with actor_id="autonomy_service"
7
+ for a complete audit trail.
8
+ """
9
+
10
+ import fnmatch
11
+ import json
12
+ import logging
13
+ import subprocess
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ from sqlalchemy import select
18
+ from sqlalchemy.ext.asyncio import AsyncSession
19
+ from sqlalchemy.orm import Session, selectinload
20
+
21
+ from app.models.enums import ActorType, EventType
22
+ from app.models.evidence import Evidence, EvidenceKind
23
+ from app.models.goal import Goal
24
+ from app.models.revision import Revision
25
+ from app.models.ticket import Ticket
26
+ from app.models.ticket_event import TicketEvent
27
+ from app.services.config_service import AutonomyConfig, DraftConfig
28
+ from app.services.workspace_service import WorkspaceService
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ @dataclass
34
+ class AutonomyCheckResult:
35
+ """Result of an autonomy safety check."""
36
+
37
+ approved: bool
38
+ reason: str
39
+
40
+
41
+ class AutonomyService:
42
+ """Core safety logic for full autonomy mode.
43
+
44
+ Provides both async (FastAPI) and sync (Celery worker) methods.
45
+ """
46
+
47
+ def __init__(self, config: AutonomyConfig | None = None):
48
+ if config is None:
49
+ config = DraftConfig().autonomy_config
50
+ self.config = config
51
+
52
+ # ── Async methods (for FastAPI routes and planner) ──
53
+
54
+ async def can_auto_approve_ticket(
55
+ self, db: AsyncSession, ticket: Ticket
56
+ ) -> AutonomyCheckResult:
57
+ """Check if a ticket can be auto-approved (PROPOSED -> PLANNED).
58
+
59
+ Checks:
60
+ - goal.autonomy_enabled
61
+ - goal.auto_approve_tickets
62
+ - max_auto_approvals not exceeded
63
+ """
64
+ result = await db.execute(select(Goal).where(Goal.id == ticket.goal_id))
65
+ goal = result.scalar_one_or_none()
66
+ if goal is None:
67
+ return AutonomyCheckResult(False, "Goal not found")
68
+
69
+ return self._check_ticket_approval(goal)
70
+
71
+ async def can_auto_approve_revision(
72
+ self, db: AsyncSession, ticket: Ticket, revision: Revision | None = None
73
+ ) -> AutonomyCheckResult:
74
+ """Check if a revision can be auto-approved (VERIFYING -> DONE).
75
+
76
+ Checks:
77
+ - goal.autonomy_enabled + goal.auto_approve_revisions
78
+ - All verification evidence has exit_code == 0
79
+ - Diff size < max_diff_lines
80
+ - No sensitive files in diff
81
+ - max_auto_approvals not reached
82
+ """
83
+ result = await db.execute(select(Goal).where(Goal.id == ticket.goal_id))
84
+ goal = result.scalar_one_or_none()
85
+ if goal is None:
86
+ return AutonomyCheckResult(False, "Goal not found")
87
+
88
+ # Load evidence for this ticket
89
+ evidence_result = await db.execute(
90
+ select(Evidence).where(Evidence.ticket_id == ticket.id)
91
+ )
92
+ evidence_list = list(evidence_result.scalars().all())
93
+
94
+ return self._check_revision_approval(goal, evidence_list)
95
+
96
+ async def can_auto_merge(
97
+ self, db: AsyncSession, ticket: Ticket, repo_path: Path
98
+ ) -> AutonomyCheckResult:
99
+ """Check if a ticket can be auto-merged after DONE.
100
+
101
+ Checks:
102
+ - goal.auto_merge
103
+ - Pre-check merge conflicts
104
+ """
105
+ result = await db.execute(select(Goal).where(Goal.id == ticket.goal_id))
106
+ goal = result.scalar_one_or_none()
107
+ if goal is None:
108
+ return AutonomyCheckResult(False, "Goal not found")
109
+
110
+ if not goal.autonomy_enabled or not goal.auto_merge:
111
+ return AutonomyCheckResult(False, "Auto-merge not enabled for this goal")
112
+
113
+ # Get workspace to find branch name
114
+ ticket_result = await db.execute(
115
+ select(Ticket)
116
+ .where(Ticket.id == ticket.id)
117
+ .options(selectinload(Ticket.workspace))
118
+ )
119
+ ticket_with_ws = ticket_result.scalar_one_or_none()
120
+ if not ticket_with_ws or not ticket_with_ws.workspace:
121
+ return AutonomyCheckResult(False, "No active workspace")
122
+
123
+ branch_name = ticket_with_ws.workspace.branch_name
124
+ return self._check_merge_conflicts(repo_path, branch_name)
125
+
126
+ async def record_auto_action(
127
+ self,
128
+ db: AsyncSession,
129
+ ticket: Ticket,
130
+ action_type: str,
131
+ details: dict,
132
+ from_state: str | None = None,
133
+ to_state: str | None = None,
134
+ ) -> None:
135
+ """Record an autonomy action as a TicketEvent and increment counter."""
136
+ event = TicketEvent(
137
+ ticket_id=ticket.id,
138
+ event_type=EventType.TRANSITIONED.value
139
+ if from_state != to_state
140
+ else EventType.COMMENT.value,
141
+ from_state=from_state or ticket.state,
142
+ to_state=to_state or ticket.state,
143
+ actor_type=ActorType.SYSTEM.value,
144
+ actor_id="autonomy_service",
145
+ reason=f"Auto-{action_type}: {details.get('reason', 'autonomy mode')}",
146
+ payload_json=json.dumps({"autonomy_action": action_type, **details}),
147
+ )
148
+ db.add(event)
149
+
150
+ # Increment auto_approval_count on goal
151
+ result = await db.execute(select(Goal).where(Goal.id == ticket.goal_id))
152
+ goal = result.scalar_one_or_none()
153
+ if goal:
154
+ goal.auto_approval_count += 1
155
+
156
+ # ── Sync methods (for Celery worker) ──
157
+
158
+ def can_auto_approve_ticket_sync(
159
+ self, db: Session, ticket: Ticket
160
+ ) -> AutonomyCheckResult:
161
+ """Sync version of can_auto_approve_ticket for Celery worker."""
162
+ goal = db.query(Goal).filter(Goal.id == ticket.goal_id).first()
163
+ if goal is None:
164
+ return AutonomyCheckResult(False, "Goal not found")
165
+ return self._check_ticket_approval(goal)
166
+
167
+ def can_auto_approve_revision_sync(
168
+ self, db: Session, ticket: Ticket
169
+ ) -> AutonomyCheckResult:
170
+ """Sync version of can_auto_approve_revision for Celery worker."""
171
+ goal = db.query(Goal).filter(Goal.id == ticket.goal_id).first()
172
+ if goal is None:
173
+ return AutonomyCheckResult(False, "Goal not found")
174
+
175
+ evidence_list = db.query(Evidence).filter(Evidence.ticket_id == ticket.id).all()
176
+ return self._check_revision_approval(goal, evidence_list)
177
+
178
+ def record_auto_action_sync(
179
+ self,
180
+ db: Session,
181
+ ticket: Ticket,
182
+ action_type: str,
183
+ details: dict,
184
+ from_state: str | None = None,
185
+ to_state: str | None = None,
186
+ ) -> None:
187
+ """Sync version of record_auto_action for Celery worker."""
188
+ event = TicketEvent(
189
+ ticket_id=ticket.id,
190
+ event_type=EventType.TRANSITIONED.value
191
+ if from_state != to_state
192
+ else EventType.COMMENT.value,
193
+ from_state=from_state or ticket.state,
194
+ to_state=to_state or ticket.state,
195
+ actor_type=ActorType.SYSTEM.value,
196
+ actor_id="autonomy_service",
197
+ reason=f"Auto-{action_type}: {details.get('reason', 'autonomy mode')}",
198
+ payload_json=json.dumps({"autonomy_action": action_type, **details}),
199
+ )
200
+ db.add(event)
201
+
202
+ goal = db.query(Goal).filter(Goal.id == ticket.goal_id).first()
203
+ if goal:
204
+ goal.auto_approval_count += 1
205
+
206
+ # ── Shared pure logic (no DB) ──
207
+
208
+ def _check_ticket_approval(self, goal: Goal) -> AutonomyCheckResult:
209
+ """Check if goal settings allow auto-approving a ticket."""
210
+ if not goal.autonomy_enabled:
211
+ return AutonomyCheckResult(False, "Autonomy not enabled for this goal")
212
+ if not goal.auto_approve_tickets:
213
+ return AutonomyCheckResult(False, "Auto-approve tickets not enabled")
214
+ if (
215
+ goal.max_auto_approvals is not None
216
+ and goal.auto_approval_count >= goal.max_auto_approvals
217
+ ):
218
+ return AutonomyCheckResult(
219
+ False, f"Max auto-approvals reached ({goal.max_auto_approvals})"
220
+ )
221
+ return AutonomyCheckResult(True, "Ticket auto-approval allowed")
222
+
223
+ def _check_revision_approval(
224
+ self, goal: Goal, evidence_list: list[Evidence]
225
+ ) -> AutonomyCheckResult:
226
+ """Check if goal settings and evidence allow auto-approving a revision."""
227
+ if not goal.autonomy_enabled:
228
+ return AutonomyCheckResult(False, "Autonomy not enabled for this goal")
229
+ if not goal.auto_approve_revisions:
230
+ return AutonomyCheckResult(False, "Auto-approve revisions not enabled")
231
+ if (
232
+ goal.max_auto_approvals is not None
233
+ and goal.auto_approval_count >= goal.max_auto_approvals
234
+ ):
235
+ return AutonomyCheckResult(
236
+ False, f"Max auto-approvals reached ({goal.max_auto_approvals})"
237
+ )
238
+
239
+ # Check verification evidence
240
+ if self.config.require_verification_pass:
241
+ verify_evidence = [
242
+ e
243
+ for e in evidence_list
244
+ if e.kind in (EvidenceKind.VERIFY_META.value, EvidenceKind.VERIFY_META)
245
+ ]
246
+ if verify_evidence:
247
+ for ve in verify_evidence:
248
+ if ve.exit_code != 0:
249
+ return AutonomyCheckResult(
250
+ False, f"Verification failed (exit_code={ve.exit_code})"
251
+ )
252
+
253
+ # Check diff size
254
+ diff_stat_evidence = [
255
+ e
256
+ for e in evidence_list
257
+ if e.kind in (EvidenceKind.GIT_DIFF_STAT.value, EvidenceKind.GIT_DIFF_STAT)
258
+ ]
259
+ if diff_stat_evidence:
260
+ total_lines = self._parse_diff_stat_lines(diff_stat_evidence[-1])
261
+ if total_lines > self.config.max_diff_lines:
262
+ return AutonomyCheckResult(
263
+ False,
264
+ f"Diff too large ({total_lines} lines > {self.config.max_diff_lines} max)",
265
+ )
266
+
267
+ # Check for sensitive files in diff
268
+ diff_patch_evidence = [
269
+ e
270
+ for e in evidence_list
271
+ if e.kind
272
+ in (EvidenceKind.GIT_DIFF_PATCH.value, EvidenceKind.GIT_DIFF_PATCH)
273
+ ]
274
+ if diff_patch_evidence:
275
+ sensitive = self._check_sensitive_files(diff_patch_evidence[-1])
276
+ if sensitive:
277
+ return AutonomyCheckResult(
278
+ False,
279
+ f"Sensitive files detected in diff: {', '.join(sensitive)}",
280
+ )
281
+
282
+ return AutonomyCheckResult(True, "Revision auto-approval allowed")
283
+
284
+ def _parse_diff_stat_lines(self, evidence: Evidence) -> int:
285
+ """Parse total lines changed from a git diff --stat evidence record.
286
+
287
+ The last line of diff stat output looks like:
288
+ N files changed, X insertions(+), Y deletions(-)
289
+ """
290
+ try:
291
+ content_path = Path(evidence.stdout_path)
292
+ if not content_path.is_absolute():
293
+ repo_root = WorkspaceService.get_repo_path()
294
+ content_path = repo_root / evidence.stdout_path
295
+ if not content_path.exists():
296
+ return 0
297
+ content = content_path.read_text()
298
+ # Parse last line for total
299
+ lines = content.strip().split("\n")
300
+ if not lines:
301
+ return 0
302
+ last_line = lines[-1]
303
+ total = 0
304
+ # Parse "X insertions(+)" and "Y deletions(-)"
305
+ for part in last_line.split(","):
306
+ part = part.strip()
307
+ if "insertion" in part or "deletion" in part:
308
+ try:
309
+ total += int(part.split()[0])
310
+ except (ValueError, IndexError):
311
+ pass
312
+ return total
313
+ except Exception:
314
+ logger.debug("Failed to parse diff stat", exc_info=True)
315
+ return 0
316
+
317
+ def _check_sensitive_files(self, evidence: Evidence) -> list[str]:
318
+ """Check if any files in a diff patch match sensitive file patterns."""
319
+ try:
320
+ content_path = Path(evidence.stdout_path)
321
+ if not content_path.is_absolute():
322
+ repo_root = WorkspaceService.get_repo_path()
323
+ content_path = repo_root / evidence.stdout_path
324
+ if not content_path.exists():
325
+ return []
326
+ content = content_path.read_text()
327
+ # Extract file paths from diff headers (--- a/path and +++ b/path)
328
+ files_in_diff = set()
329
+ for line in content.split("\n"):
330
+ if line.startswith("+++ b/") or line.startswith("--- a/"):
331
+ path = line[6:] # Remove "+++ b/" or "--- a/"
332
+ if path != "/dev/null":
333
+ files_in_diff.add(path)
334
+
335
+ # Match against sensitive patterns
336
+ matches = []
337
+ for file_path in files_in_diff:
338
+ for pattern in self.config.sensitive_file_patterns:
339
+ if fnmatch.fnmatch(file_path, pattern):
340
+ matches.append(file_path)
341
+ break
342
+ return matches
343
+ except Exception:
344
+ logger.debug("Failed to check sensitive files", exc_info=True)
345
+ return []
346
+
347
+ def _check_merge_conflicts(
348
+ self, repo_path: Path, branch_name: str
349
+ ) -> AutonomyCheckResult:
350
+ """Pre-check for merge conflicts using git merge --no-commit --no-ff."""
351
+ try:
352
+ # Try merge dry-run
353
+ result = subprocess.run(
354
+ ["git", "merge", "--no-commit", "--no-ff", branch_name],
355
+ cwd=repo_path,
356
+ capture_output=True,
357
+ text=True,
358
+ timeout=30,
359
+ )
360
+
361
+ # Always abort the test merge
362
+ subprocess.run(
363
+ ["git", "merge", "--abort"],
364
+ cwd=repo_path,
365
+ capture_output=True,
366
+ timeout=10,
367
+ )
368
+
369
+ if result.returncode != 0:
370
+ return AutonomyCheckResult(
371
+ False,
372
+ f"Merge conflicts detected: {result.stderr.strip()[:200]}",
373
+ )
374
+
375
+ return AutonomyCheckResult(True, "No merge conflicts detected")
376
+
377
+ except subprocess.TimeoutExpired:
378
+ return AutonomyCheckResult(False, "Merge conflict check timed out")
379
+ except Exception as e:
380
+ return AutonomyCheckResult(False, f"Merge conflict check failed: {e}")
@@ -0,0 +1,201 @@
1
+ """Service for managing Board-Repo associations."""
2
+
3
+ import uuid
4
+
5
+ from sqlalchemy import select
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+ from sqlalchemy.orm import joinedload
8
+
9
+ from app.models.board import Board
10
+ from app.models.board_repo import BoardRepo
11
+ from app.models.repo import Repo
12
+
13
+
14
+ class BoardRepoService:
15
+ """Service for managing board-repo relationships."""
16
+
17
+ def __init__(self, db: AsyncSession):
18
+ self.db = db
19
+
20
+ async def add_repo_to_board(
21
+ self,
22
+ board_id: str,
23
+ repo_id: str,
24
+ is_primary: bool = False,
25
+ custom_setup_script: str | None = None,
26
+ custom_cleanup_script: str | None = None,
27
+ ) -> BoardRepo:
28
+ """
29
+ Add a repository to a board.
30
+
31
+ Args:
32
+ board_id: Board UUID
33
+ repo_id: Repo UUID
34
+ is_primary: Whether this is the primary repo
35
+ custom_setup_script: Per-board setup script override
36
+ custom_cleanup_script: Per-board cleanup script override
37
+
38
+ Returns:
39
+ Created BoardRepo association
40
+
41
+ Raises:
42
+ ValueError: If board or repo not found, or association already exists
43
+ """
44
+ # Verify board exists
45
+ board_result = await self.db.execute(select(Board).where(Board.id == board_id))
46
+ board = board_result.scalar_one_or_none()
47
+ if not board:
48
+ raise ValueError(f"Board not found: {board_id}")
49
+
50
+ # Verify repo exists
51
+ repo_result = await self.db.execute(select(Repo).where(Repo.id == repo_id))
52
+ repo = repo_result.scalar_one_or_none()
53
+ if not repo:
54
+ raise ValueError(f"Repo not found: {repo_id}")
55
+
56
+ # Check if association already exists
57
+ existing_result = await self.db.execute(
58
+ select(BoardRepo).where(
59
+ BoardRepo.board_id == board_id, BoardRepo.repo_id == repo_id
60
+ )
61
+ )
62
+ existing = existing_result.scalar_one_or_none()
63
+ if existing:
64
+ raise ValueError(
65
+ f"Repo {repo_id} is already associated with board {board_id}"
66
+ )
67
+
68
+ # If setting as primary, unset other primary repos
69
+ if is_primary:
70
+ await self._unset_primary_repos(board_id)
71
+
72
+ # Create association
73
+ board_repo = BoardRepo(
74
+ id=str(uuid.uuid4()),
75
+ board_id=board_id,
76
+ repo_id=repo_id,
77
+ is_primary=is_primary,
78
+ custom_setup_script=custom_setup_script,
79
+ custom_cleanup_script=custom_cleanup_script,
80
+ )
81
+
82
+ self.db.add(board_repo)
83
+ await self.db.commit()
84
+ await self.db.refresh(board_repo)
85
+
86
+ # Eager load repo relationship
87
+ result = await self.db.execute(
88
+ select(BoardRepo)
89
+ .options(joinedload(BoardRepo.repo))
90
+ .where(BoardRepo.id == board_repo.id)
91
+ )
92
+ return result.scalar_one()
93
+
94
+ async def get_board_repos(self, board_id: str) -> list[BoardRepo]:
95
+ """
96
+ Get all repos associated with a board.
97
+
98
+ Returns:
99
+ List of BoardRepo associations with eager-loaded Repo
100
+ """
101
+ result = await self.db.execute(
102
+ select(BoardRepo)
103
+ .options(joinedload(BoardRepo.repo))
104
+ .where(BoardRepo.board_id == board_id)
105
+ .order_by(BoardRepo.is_primary.desc(), BoardRepo.created_at.asc())
106
+ )
107
+ return list(result.scalars().all())
108
+
109
+ async def get_board_repo(self, board_id: str, repo_id: str) -> BoardRepo | None:
110
+ """Get a specific board-repo association."""
111
+ result = await self.db.execute(
112
+ select(BoardRepo)
113
+ .options(joinedload(BoardRepo.repo))
114
+ .where(BoardRepo.board_id == board_id, BoardRepo.repo_id == repo_id)
115
+ )
116
+ return result.scalar_one_or_none()
117
+
118
+ async def update_board_repo(
119
+ self,
120
+ board_id: str,
121
+ repo_id: str,
122
+ is_primary: bool | None = None,
123
+ custom_setup_script: str | None = None,
124
+ custom_cleanup_script: str | None = None,
125
+ ) -> BoardRepo:
126
+ """
127
+ Update a board-repo association.
128
+
129
+ Args:
130
+ board_id: Board UUID
131
+ repo_id: Repo UUID
132
+ is_primary: Whether to set as primary
133
+ custom_setup_script: Per-board setup script override
134
+ custom_cleanup_script: Per-board cleanup script override
135
+
136
+ Returns:
137
+ Updated BoardRepo
138
+
139
+ Raises:
140
+ ValueError: If association not found
141
+ """
142
+ board_repo = await self.get_board_repo(board_id, repo_id)
143
+ if not board_repo:
144
+ raise ValueError(
145
+ f"Board-repo association not found: board={board_id}, repo={repo_id}"
146
+ )
147
+
148
+ # If setting as primary, unset other primary repos
149
+ if is_primary is not None and is_primary and not board_repo.is_primary:
150
+ await self._unset_primary_repos(board_id)
151
+ board_repo.is_primary = True
152
+
153
+ if custom_setup_script is not None:
154
+ board_repo.custom_setup_script = custom_setup_script
155
+ if custom_cleanup_script is not None:
156
+ board_repo.custom_cleanup_script = custom_cleanup_script
157
+
158
+ await self.db.commit()
159
+ await self.db.refresh(board_repo)
160
+
161
+ # Reload with repo relationship
162
+ result = await self.db.execute(
163
+ select(BoardRepo)
164
+ .options(joinedload(BoardRepo.repo))
165
+ .where(BoardRepo.id == board_repo.id)
166
+ )
167
+ return result.scalar_one()
168
+
169
+ async def remove_repo_from_board(self, board_id: str, repo_id: str) -> None:
170
+ """
171
+ Remove a repo from a board.
172
+
173
+ Args:
174
+ board_id: Board UUID
175
+ repo_id: Repo UUID
176
+
177
+ Raises:
178
+ ValueError: If association not found
179
+ """
180
+ board_repo = await self.get_board_repo(board_id, repo_id)
181
+ if not board_repo:
182
+ raise ValueError(
183
+ f"Board-repo association not found: board={board_id}, repo={repo_id}"
184
+ )
185
+
186
+ await self.db.delete(board_repo)
187
+ await self.db.commit()
188
+
189
+ async def _unset_primary_repos(self, board_id: str) -> None:
190
+ """Unset is_primary for all repos on a board."""
191
+ result = await self.db.execute(
192
+ select(BoardRepo).where(
193
+ BoardRepo.board_id == board_id, BoardRepo.is_primary
194
+ )
195
+ )
196
+ primary_repos = list(result.scalars().all())
197
+
198
+ for board_repo in primary_repos:
199
+ board_repo.is_primary = False
200
+
201
+ await self.db.flush()