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,318 @@
1
+ """Service for discovering and managing git repositories."""
2
+
3
+ import os
4
+ import uuid
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ import git
9
+ from sqlalchemy import select
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+
12
+ from app.models.repo import Repo
13
+
14
+
15
+ @dataclass
16
+ class DiscoveredRepo:
17
+ """Represents a discovered git repository."""
18
+
19
+ path: str
20
+ name: str
21
+ display_name: str
22
+ default_branch: str | None = None
23
+ remote_url: str | None = None
24
+ is_valid: bool = True
25
+ error_message: str | None = None
26
+
27
+
28
+ @dataclass
29
+ class RepoValidation:
30
+ """Result of repository path validation."""
31
+
32
+ is_valid: bool
33
+ path: str
34
+ error_message: str | None = None
35
+ metadata: DiscoveredRepo | None = None
36
+
37
+
38
+ class RepoDiscoveryService:
39
+ """Service to discover and register git repositories."""
40
+
41
+ def __init__(self, db: AsyncSession):
42
+ self.db = db
43
+
44
+ # Patterns to exclude from discovery
45
+ EXCLUDE_PATTERNS = {
46
+ "node_modules",
47
+ ".git",
48
+ "vendor",
49
+ "venv",
50
+ ".venv",
51
+ "env",
52
+ "__pycache__",
53
+ ".pytest_cache",
54
+ "dist",
55
+ "build",
56
+ ".tox",
57
+ ".eggs",
58
+ "target", # Rust
59
+ ".gradle", # Gradle
60
+ ".mvn", # Maven
61
+ }
62
+
63
+ async def discover_repos(
64
+ self,
65
+ search_paths: list[str],
66
+ max_depth: int = 3,
67
+ exclude_patterns: set[str] | None = None,
68
+ ) -> list[DiscoveredRepo]:
69
+ """
70
+ Scan directories for git repositories.
71
+
72
+ Args:
73
+ search_paths: List of directories to scan
74
+ max_depth: How deep to recurse (default 3)
75
+ exclude_patterns: Additional patterns to exclude
76
+
77
+ Returns:
78
+ List of discovered git repositories
79
+ """
80
+ if exclude_patterns is None:
81
+ exclude_patterns = self.EXCLUDE_PATTERNS.copy()
82
+ else:
83
+ exclude_patterns = self.EXCLUDE_PATTERNS | exclude_patterns
84
+
85
+ discovered = []
86
+
87
+ for search_path in search_paths:
88
+ # Expand user path (~)
89
+ expanded_path = os.path.expanduser(search_path)
90
+ path_obj = Path(expanded_path).resolve()
91
+
92
+ if not path_obj.exists():
93
+ continue
94
+
95
+ # Walk directory tree
96
+ for root, dirs, _ in os.walk(path_obj, topdown=True):
97
+ # Calculate current depth
98
+ rel_path = Path(root).relative_to(path_obj)
99
+ depth = len(rel_path.parts) if rel_path != Path(".") else 0
100
+
101
+ # Prune search if too deep
102
+ if depth >= max_depth:
103
+ dirs.clear()
104
+ continue
105
+
106
+ # Prune excluded directories
107
+ dirs[:] = [d for d in dirs if d not in exclude_patterns]
108
+
109
+ # Check if this directory is a git repo
110
+ git_dir = Path(root) / ".git"
111
+ if git_dir.exists():
112
+ # Found a git repo
113
+ repo_path = str(Path(root).resolve())
114
+ validation = self._validate_repo_path_sync(repo_path)
115
+
116
+ if validation.is_valid and validation.metadata:
117
+ discovered.append(validation.metadata)
118
+
119
+ # Don't recurse into git repos (avoid submodules)
120
+ dirs.clear()
121
+
122
+ return discovered
123
+
124
+ async def validate_repo_path(self, path: str) -> RepoValidation:
125
+ """
126
+ Validate a path is a valid git repository.
127
+
128
+ Returns:
129
+ RepoValidation with is_valid, error_message, and metadata
130
+ """
131
+ return self._validate_repo_path_sync(path)
132
+
133
+ def _validate_repo_path_sync(self, path: str) -> RepoValidation:
134
+ """Synchronous version of validate_repo_path (for use in discover_repos)."""
135
+ try:
136
+ # Expand and resolve path
137
+ expanded = os.path.expanduser(path)
138
+ path_obj = Path(expanded).resolve()
139
+
140
+ # Check if path exists
141
+ if not path_obj.exists():
142
+ return RepoValidation(
143
+ is_valid=False,
144
+ path=str(path_obj),
145
+ error_message=f"Path does not exist: {path_obj}",
146
+ )
147
+
148
+ # Check if it's a directory
149
+ if not path_obj.is_dir():
150
+ return RepoValidation(
151
+ is_valid=False,
152
+ path=str(path_obj),
153
+ error_message=f"Path is not a directory: {path_obj}",
154
+ )
155
+
156
+ # Try to open as git repo
157
+ try:
158
+ repo = git.Repo(str(path_obj))
159
+ except git.InvalidGitRepositoryError:
160
+ return RepoValidation(
161
+ is_valid=False,
162
+ path=str(path_obj),
163
+ error_message=f"Path is not a git repository: {path_obj}",
164
+ )
165
+
166
+ # Extract metadata
167
+ name = path_obj.name
168
+ display_name = name
169
+
170
+ # Get default branch
171
+ try:
172
+ default_branch = repo.active_branch.name
173
+ except Exception:
174
+ # Detached HEAD or other issue
175
+ default_branch = "main"
176
+
177
+ # Get remote URL
178
+ remote_url = None
179
+ try:
180
+ if repo.remotes:
181
+ remote_url = repo.remotes.origin.url
182
+ except Exception:
183
+ pass
184
+
185
+ metadata = DiscoveredRepo(
186
+ path=str(path_obj),
187
+ name=name,
188
+ display_name=display_name,
189
+ default_branch=default_branch,
190
+ remote_url=remote_url,
191
+ is_valid=True,
192
+ )
193
+
194
+ return RepoValidation(
195
+ is_valid=True,
196
+ path=str(path_obj),
197
+ metadata=metadata,
198
+ )
199
+
200
+ except Exception as e:
201
+ return RepoValidation(
202
+ is_valid=False,
203
+ path=path,
204
+ error_message=f"Validation error: {str(e)}",
205
+ )
206
+
207
+ async def register_repo(
208
+ self,
209
+ path: str,
210
+ display_name: str | None = None,
211
+ setup_script: str | None = None,
212
+ cleanup_script: str | None = None,
213
+ dev_server_script: str | None = None,
214
+ ) -> Repo:
215
+ """
216
+ Register a repository in the global registry.
217
+
218
+ Args:
219
+ path: Filesystem path to git repository
220
+ display_name: Optional user-friendly name
221
+ setup_script: Optional setup script
222
+ cleanup_script: Optional cleanup script
223
+ dev_server_script: Optional dev server script
224
+
225
+ Returns:
226
+ Created Repo model
227
+
228
+ Raises:
229
+ ValueError: If path is invalid or repo already exists
230
+ """
231
+ # Validate path
232
+ validation = await self.validate_repo_path(path)
233
+ if not validation.is_valid:
234
+ raise ValueError(validation.error_message or "Invalid repository path")
235
+
236
+ if not validation.metadata:
237
+ raise ValueError("Repository validation returned no metadata")
238
+
239
+ # Check if repo already exists
240
+ result = await self.db.execute(select(Repo).where(Repo.path == validation.path))
241
+ existing = result.scalar_one_or_none()
242
+ if existing:
243
+ raise ValueError(f"Repository already registered: {validation.path}")
244
+
245
+ # Create repo
246
+ metadata = validation.metadata
247
+ repo = Repo(
248
+ id=str(uuid.uuid4()),
249
+ path=validation.path,
250
+ name=metadata.name,
251
+ display_name=display_name or metadata.display_name,
252
+ setup_script=setup_script,
253
+ cleanup_script=cleanup_script,
254
+ dev_server_script=dev_server_script,
255
+ default_branch=metadata.default_branch,
256
+ remote_url=metadata.remote_url,
257
+ )
258
+
259
+ self.db.add(repo)
260
+ await self.db.commit()
261
+ await self.db.refresh(repo)
262
+
263
+ return repo
264
+
265
+ async def get_repo_by_id(self, repo_id: str) -> Repo | None:
266
+ """Get a repo by its ID."""
267
+ result = await self.db.execute(select(Repo).where(Repo.id == repo_id))
268
+ return result.scalar_one_or_none()
269
+
270
+ async def get_repo_by_path(self, path: str) -> Repo | None:
271
+ """Get a repo by its path."""
272
+ # Normalize path
273
+ expanded = os.path.expanduser(path)
274
+ normalized = str(Path(expanded).resolve())
275
+
276
+ result = await self.db.execute(select(Repo).where(Repo.path == normalized))
277
+ return result.scalar_one_or_none()
278
+
279
+ async def get_all_repos(self) -> list[Repo]:
280
+ """Get all registered repos."""
281
+ result = await self.db.execute(select(Repo).order_by(Repo.created_at.desc()))
282
+ return list(result.scalars().all())
283
+
284
+ async def update_repo(
285
+ self,
286
+ repo_id: str,
287
+ display_name: str | None = None,
288
+ setup_script: str | None = None,
289
+ cleanup_script: str | None = None,
290
+ dev_server_script: str | None = None,
291
+ ) -> Repo:
292
+ """Update a repo's configuration."""
293
+ repo = await self.get_repo_by_id(repo_id)
294
+ if not repo:
295
+ raise ValueError(f"Repo not found: {repo_id}")
296
+
297
+ if display_name is not None:
298
+ repo.display_name = display_name
299
+ if setup_script is not None:
300
+ repo.setup_script = setup_script
301
+ if cleanup_script is not None:
302
+ repo.cleanup_script = cleanup_script
303
+ if dev_server_script is not None:
304
+ repo.dev_server_script = dev_server_script
305
+
306
+ await self.db.commit()
307
+ await self.db.refresh(repo)
308
+
309
+ return repo
310
+
311
+ async def delete_repo(self, repo_id: str) -> None:
312
+ """Delete a repo from the registry."""
313
+ repo = await self.get_repo_by_id(repo_id)
314
+ if not repo:
315
+ raise ValueError(f"Repo not found: {repo_id}")
316
+
317
+ await self.db.delete(repo)
318
+ await self.db.commit()
@@ -0,0 +1,334 @@
1
+ """Service layer for Review operations (comments and summaries)."""
2
+
3
+ import logging
4
+
5
+ from sqlalchemy import select
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+ from sqlalchemy.orm import selectinload
8
+
9
+ from app.exceptions import ResourceNotFoundError, ValidationError
10
+ from app.models.review_comment import AuthorType, ReviewComment
11
+ from app.models.review_summary import ReviewDecision, ReviewSummary
12
+ from app.models.revision import Revision, RevisionStatus
13
+ from app.schemas.review import FeedbackBundle, FeedbackComment
14
+ from app.services.revision_service import compute_anchor
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class ReviewService:
20
+ """Service class for Review business logic (comments and summaries)."""
21
+
22
+ def __init__(self, db: AsyncSession):
23
+ self.db = db
24
+
25
+ # ==================== Comment Operations ====================
26
+
27
+ async def add_comment(
28
+ self,
29
+ revision_id: str,
30
+ file_path: str,
31
+ line_number: int,
32
+ body: str,
33
+ author_type: AuthorType = AuthorType.HUMAN,
34
+ hunk_header: str | None = None,
35
+ line_content: str | None = None,
36
+ ) -> ReviewComment:
37
+ """Add an inline comment to a revision.
38
+
39
+ Args:
40
+ revision_id: The UUID of the revision
41
+ file_path: Path to the file being commented on
42
+ line_number: Line number in the new file
43
+ body: Comment body text
44
+ author_type: Type of author (human, agent, system)
45
+ hunk_header: Optional diff hunk header for anchor computation
46
+ line_content: Optional line content for anchor computation
47
+
48
+ Returns:
49
+ The created ReviewComment instance
50
+
51
+ Raises:
52
+ ResourceNotFoundError: If revision not found
53
+ ConflictError: If revision is superseded
54
+ """
55
+ # Verify revision exists
56
+ result = await self.db.execute(
57
+ select(Revision).where(Revision.id == revision_id)
58
+ )
59
+ revision = result.scalar_one_or_none()
60
+ if revision is None:
61
+ raise ResourceNotFoundError("Revision", revision_id)
62
+
63
+ # Block comments on superseded revisions
64
+ if revision.status == RevisionStatus.SUPERSEDED.value:
65
+ from app.exceptions import ConflictError
66
+
67
+ raise ConflictError(
68
+ "Revision is superseded. Please review the latest revision."
69
+ )
70
+
71
+ # Compute anchor - use provided values or fallback
72
+ anchor = compute_anchor(
73
+ file_path=file_path,
74
+ hunk_header=hunk_header or "",
75
+ line_content=line_content or f"line:{line_number}",
76
+ )
77
+
78
+ comment = ReviewComment(
79
+ revision_id=revision_id,
80
+ file_path=file_path,
81
+ line_number=line_number,
82
+ anchor=anchor,
83
+ line_content=line_content,
84
+ body=body,
85
+ author_type=author_type.value,
86
+ resolved=False,
87
+ )
88
+ self.db.add(comment)
89
+ await self.db.flush()
90
+ await self.db.refresh(comment)
91
+
92
+ logger.info(
93
+ f"Added comment {comment.id} on revision {revision_id} at {file_path}:{line_number}"
94
+ )
95
+ return comment
96
+
97
+ async def get_comments_for_revision(
98
+ self, revision_id: str, include_resolved: bool = True
99
+ ) -> list[ReviewComment]:
100
+ """Get all comments for a revision.
101
+
102
+ Args:
103
+ revision_id: The UUID of the revision
104
+ include_resolved: Whether to include resolved comments
105
+
106
+ Returns:
107
+ List of ReviewComment instances ordered by creation time
108
+ """
109
+ # Verify revision exists
110
+ result = await self.db.execute(
111
+ select(Revision).where(Revision.id == revision_id)
112
+ )
113
+ if result.scalar_one_or_none() is None:
114
+ raise ResourceNotFoundError("Revision", revision_id)
115
+
116
+ query = select(ReviewComment).where(ReviewComment.revision_id == revision_id)
117
+
118
+ if not include_resolved:
119
+ query = query.where(ReviewComment.resolved == False) # noqa: E712
120
+
121
+ query = query.order_by(ReviewComment.created_at.asc())
122
+
123
+ result = await self.db.execute(query)
124
+ return list(result.scalars().all())
125
+
126
+ async def get_comment_by_id(self, comment_id: str) -> ReviewComment:
127
+ """Get a comment by its ID.
128
+
129
+ Args:
130
+ comment_id: The UUID of the comment
131
+
132
+ Returns:
133
+ The ReviewComment instance
134
+
135
+ Raises:
136
+ ResourceNotFoundError: If the comment is not found
137
+ """
138
+ result = await self.db.execute(
139
+ select(ReviewComment).where(ReviewComment.id == comment_id)
140
+ )
141
+ comment = result.scalar_one_or_none()
142
+ if comment is None:
143
+ raise ResourceNotFoundError("ReviewComment", comment_id)
144
+ return comment
145
+
146
+ async def resolve_comment(self, comment_id: str) -> ReviewComment:
147
+ """Mark a comment as resolved.
148
+
149
+ Args:
150
+ comment_id: The UUID of the comment
151
+
152
+ Returns:
153
+ The updated ReviewComment instance
154
+ """
155
+ comment = await self.get_comment_by_id(comment_id)
156
+ comment.resolved = True
157
+ await self.db.flush()
158
+ await self.db.refresh(comment)
159
+ logger.info(f"Resolved comment {comment_id}")
160
+ return comment
161
+
162
+ async def unresolve_comment(self, comment_id: str) -> ReviewComment:
163
+ """Mark a comment as unresolved.
164
+
165
+ Args:
166
+ comment_id: The UUID of the comment
167
+
168
+ Returns:
169
+ The updated ReviewComment instance
170
+ """
171
+ comment = await self.get_comment_by_id(comment_id)
172
+ comment.resolved = False
173
+ await self.db.flush()
174
+ await self.db.refresh(comment)
175
+ logger.info(f"Unresolved comment {comment_id}")
176
+ return comment
177
+
178
+ async def get_unresolved_count(self, revision_id: str) -> int:
179
+ """Get the count of unresolved comments for a revision.
180
+
181
+ Args:
182
+ revision_id: The UUID of the revision
183
+
184
+ Returns:
185
+ Number of unresolved comments
186
+ """
187
+ result = await self.db.execute(
188
+ select(ReviewComment).where(
189
+ ReviewComment.revision_id == revision_id,
190
+ ReviewComment.resolved == False, # noqa: E712
191
+ )
192
+ )
193
+ return len(list(result.scalars().all()))
194
+
195
+ # ==================== Review Summary Operations ====================
196
+
197
+ async def submit_review(
198
+ self,
199
+ revision_id: str,
200
+ decision: ReviewDecision,
201
+ summary: str,
202
+ ) -> ReviewSummary:
203
+ """Submit a review decision for a revision.
204
+
205
+ Args:
206
+ revision_id: The UUID of the revision
207
+ decision: The review decision (approved or changes_requested)
208
+ summary: High-level review feedback
209
+
210
+ Returns:
211
+ The created ReviewSummary instance
212
+
213
+ Raises:
214
+ ValidationError: If unresolved comments exist when approving
215
+ ConflictError: If revision is superseded
216
+ """
217
+ # Get revision with comments
218
+ result = await self.db.execute(
219
+ select(Revision)
220
+ .where(Revision.id == revision_id)
221
+ .options(
222
+ selectinload(Revision.comments),
223
+ selectinload(Revision.review_summary),
224
+ )
225
+ )
226
+ revision = result.scalar_one_or_none()
227
+ if revision is None:
228
+ raise ResourceNotFoundError("Revision", revision_id)
229
+
230
+ # Block reviews on superseded revisions
231
+ if revision.status == RevisionStatus.SUPERSEDED.value:
232
+ from app.exceptions import ConflictError
233
+
234
+ raise ConflictError(
235
+ "Revision is superseded. Please review the latest revision."
236
+ )
237
+
238
+ # Check if review already exists
239
+ if revision.review_summary:
240
+ raise ValidationError("This revision already has a review submitted")
241
+
242
+ # Note: We allow approval even with unresolved comments.
243
+ # Comments are informational notes; approving accepts the changes.
244
+ # Requesting changes sends all unresolved comments to the agent as feedback.
245
+
246
+ # Create the review summary
247
+ review_summary = ReviewSummary(
248
+ revision_id=revision_id,
249
+ decision=decision.value,
250
+ body=summary,
251
+ )
252
+ self.db.add(review_summary)
253
+
254
+ # Update revision status based on decision
255
+ if decision == ReviewDecision.APPROVED:
256
+ revision.status = RevisionStatus.APPROVED.value
257
+ else:
258
+ revision.status = RevisionStatus.CHANGES_REQUESTED.value
259
+
260
+ await self.db.flush()
261
+ await self.db.refresh(review_summary)
262
+
263
+ logger.info(f"Submitted review for revision {revision_id}: {decision.value}")
264
+ return review_summary
265
+
266
+ async def get_review_summary(self, revision_id: str) -> ReviewSummary | None:
267
+ """Get the review summary for a revision.
268
+
269
+ Args:
270
+ revision_id: The UUID of the revision
271
+
272
+ Returns:
273
+ The ReviewSummary instance or None if no review exists
274
+ """
275
+ result = await self.db.execute(
276
+ select(ReviewSummary).where(ReviewSummary.revision_id == revision_id)
277
+ )
278
+ return result.scalar_one_or_none()
279
+
280
+ # ==================== Feedback Bundle ====================
281
+
282
+ async def get_feedback_bundle(self, revision_id: str) -> FeedbackBundle:
283
+ """Get the feedback bundle for a revision.
284
+
285
+ This is the structured feedback that gets injected into the agent prompt
286
+ when creating a new revision after changes are requested.
287
+
288
+ Args:
289
+ revision_id: The UUID of the revision
290
+
291
+ Returns:
292
+ FeedbackBundle containing all review feedback
293
+ """
294
+ # Get revision with all related data
295
+ result = await self.db.execute(
296
+ select(Revision)
297
+ .where(Revision.id == revision_id)
298
+ .options(
299
+ selectinload(Revision.comments),
300
+ selectinload(Revision.review_summary),
301
+ )
302
+ )
303
+ revision = result.scalar_one_or_none()
304
+ if revision is None:
305
+ raise ResourceNotFoundError("Revision", revision_id)
306
+
307
+ # Build feedback comments (only unresolved ones are actionable)
308
+ feedback_comments = [
309
+ FeedbackComment(
310
+ file_path=comment.file_path,
311
+ line_number=comment.line_number,
312
+ anchor=comment.anchor,
313
+ body=comment.body,
314
+ line_content=comment.line_content,
315
+ )
316
+ for comment in revision.comments
317
+ if not comment.resolved
318
+ ]
319
+
320
+ # Get review summary
321
+ summary_text = ""
322
+ decision_text = "pending"
323
+ if revision.review_summary:
324
+ summary_text = revision.review_summary.body
325
+ decision_text = revision.review_summary.decision
326
+
327
+ return FeedbackBundle(
328
+ ticket_id=revision.ticket_id,
329
+ revision_id=revision_id,
330
+ revision_number=revision.number,
331
+ decision=decision_text,
332
+ summary=summary_text,
333
+ comments=feedback_comments,
334
+ )