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,379 @@
1
+ """LangChain tools for UDAR agent.
2
+
3
+ These tools wrap existing Draft services to make them accessible
4
+ to the LangGraph agent. All tools are designed to be deterministic and
5
+ minimize LLM calls where possible.
6
+ """
7
+
8
+ import json
9
+ from pathlib import Path
10
+
11
+ from langchain_core.tools import tool
12
+ from sqlalchemy import select
13
+ from sqlalchemy.ext.asyncio import AsyncSession
14
+
15
+ from app.models.goal import Goal
16
+ from app.models.ticket import Ticket
17
+ from app.services.context_gatherer import ContextGatherer
18
+
19
+
20
+ @tool
21
+ async def analyze_codebase(repo_root: str) -> str:
22
+ """Analyze repository structure and gather codebase context.
23
+
24
+ This is a DETERMINISTIC tool (0 LLM calls). It uses the existing
25
+ ContextGatherer to scan the repository and return metadata about:
26
+ - Project type (python, node, mixed, etc.)
27
+ - File tree with line counts
28
+ - TODO comments
29
+ - README excerpt
30
+
31
+ Args:
32
+ repo_root: Absolute path to repository root
33
+
34
+ Returns:
35
+ JSON string with codebase context:
36
+ {
37
+ "project_type": "python",
38
+ "file_count": 150,
39
+ "total_lines": 12500,
40
+ "todo_count": 23,
41
+ "has_readme": true,
42
+ "readme_excerpt": "...",
43
+ "file_tree_sample": ["backend/app/main.py", ...]
44
+ }
45
+ """
46
+ try:
47
+ gatherer = ContextGatherer(max_files=1000)
48
+ context = gatherer.gather(
49
+ repo_root=Path(repo_root),
50
+ include_readme_excerpt=True,
51
+ )
52
+
53
+ # Convert to JSON-serializable format
54
+ result = {
55
+ "project_type": context.project_type,
56
+ "file_count": len(context.file_tree),
57
+ "total_lines": sum(f.line_count for f in context.file_tree),
58
+ "todo_count": context.todo_count,
59
+ "has_readme": bool(context.readme_excerpt),
60
+ "readme_excerpt": context.readme_excerpt[:500]
61
+ if context.readme_excerpt
62
+ else None,
63
+ "file_tree_sample": [
64
+ f.path for f in context.file_tree[:50]
65
+ ], # Cap at 50 files
66
+ "stats": {
67
+ "files_scanned": context.stats.files_scanned,
68
+ "bytes_read": context.stats.bytes_read,
69
+ "excluded_count": context.stats.skipped_excluded,
70
+ },
71
+ }
72
+
73
+ return json.dumps(result, indent=2)
74
+
75
+ except Exception as e:
76
+ return json.dumps(
77
+ {
78
+ "error": str(e),
79
+ "project_type": "unknown",
80
+ "file_count": 0,
81
+ }
82
+ )
83
+
84
+
85
+ @tool
86
+ async def search_tickets(
87
+ db: AsyncSession,
88
+ goal_id: str,
89
+ query: str | None = None,
90
+ state: str | None = None,
91
+ ) -> str:
92
+ """Search existing tickets for a goal.
93
+
94
+ This is a DETERMINISTIC tool (0 LLM calls). It queries the database
95
+ to find tickets matching the criteria. Useful for avoiding duplicate
96
+ ticket generation.
97
+
98
+ Args:
99
+ db: Database session
100
+ goal_id: Goal ID to search within
101
+ query: Optional text to search in title/description
102
+ state: Optional state filter (e.g., "done", "planned")
103
+
104
+ Returns:
105
+ JSON string with ticket list:
106
+ {
107
+ "total": 5,
108
+ "tickets": [
109
+ {
110
+ "id": "abc-123",
111
+ "title": "Add authentication",
112
+ "state": "done",
113
+ "priority": 90,
114
+ "blocked_by_ticket_id": null
115
+ },
116
+ ...
117
+ ]
118
+ }
119
+ """
120
+ try:
121
+ # Build query
122
+ stmt = select(Ticket).where(Ticket.goal_id == goal_id)
123
+
124
+ if state:
125
+ stmt = stmt.where(Ticket.state == state)
126
+
127
+ if query:
128
+ # Simple text search in title and description
129
+ search_term = f"%{query.lower()}%"
130
+ stmt = stmt.where(
131
+ (Ticket.title.ilike(search_term))
132
+ | (Ticket.description.ilike(search_term))
133
+ )
134
+
135
+ # Execute query
136
+ result = await db.execute(stmt)
137
+ tickets = result.scalars().all()
138
+
139
+ # Convert to JSON-serializable format
140
+ tickets_data = [
141
+ {
142
+ "id": t.id,
143
+ "title": t.title,
144
+ "description": t.description[:200]
145
+ if t.description
146
+ else None, # Cap at 200 chars
147
+ "state": t.state,
148
+ "priority": t.priority,
149
+ "blocked_by_ticket_id": t.blocked_by_ticket_id,
150
+ }
151
+ for t in tickets
152
+ ]
153
+
154
+ return json.dumps(
155
+ {
156
+ "total": len(tickets_data),
157
+ "tickets": tickets_data,
158
+ },
159
+ indent=2,
160
+ )
161
+
162
+ except Exception as e:
163
+ return json.dumps(
164
+ {
165
+ "error": str(e),
166
+ "total": 0,
167
+ "tickets": [],
168
+ }
169
+ )
170
+
171
+
172
+ @tool
173
+ async def get_goal_context(db: AsyncSession, goal_id: str) -> str:
174
+ """Get goal details and statistics.
175
+
176
+ This is a DETERMINISTIC tool (0 LLM calls). It retrieves the goal
177
+ and counts existing tickets by state.
178
+
179
+ Args:
180
+ db: Database session
181
+ goal_id: Goal ID to retrieve
182
+
183
+ Returns:
184
+ JSON string with goal context:
185
+ {
186
+ "id": "goal-123",
187
+ "title": "Add authentication system",
188
+ "description": "Implement OAuth2...",
189
+ "status": "active",
190
+ "ticket_counts": {
191
+ "proposed": 2,
192
+ "planned": 3,
193
+ "executing": 1,
194
+ "done": 5,
195
+ "total": 11
196
+ }
197
+ }
198
+ """
199
+ try:
200
+ # Get goal
201
+ goal = await db.get(Goal, goal_id)
202
+ if not goal:
203
+ return json.dumps(
204
+ {
205
+ "error": f"Goal {goal_id} not found",
206
+ "id": goal_id,
207
+ }
208
+ )
209
+
210
+ # Count tickets by state
211
+ stmt = select(Ticket).where(Ticket.goal_id == goal_id)
212
+ result = await db.execute(stmt)
213
+ tickets = result.scalars().all()
214
+
215
+ ticket_counts = {}
216
+ for ticket in tickets:
217
+ state = ticket.state
218
+ ticket_counts[state] = ticket_counts.get(state, 0) + 1
219
+
220
+ # Build result
221
+ result_data = {
222
+ "id": goal.id,
223
+ "title": goal.title,
224
+ "description": goal.description[:500]
225
+ if goal.description
226
+ else None, # Cap at 500 chars
227
+ "created_at": goal.created_at.isoformat() if goal.created_at else None,
228
+ "ticket_counts": {
229
+ **ticket_counts,
230
+ "total": len(tickets),
231
+ },
232
+ }
233
+
234
+ return json.dumps(result_data, indent=2)
235
+
236
+ except Exception as e:
237
+ return json.dumps(
238
+ {
239
+ "error": str(e),
240
+ "id": goal_id,
241
+ }
242
+ )
243
+
244
+
245
+ @tool
246
+ async def analyze_ticket_changes(
247
+ db: AsyncSession,
248
+ ticket_id: str,
249
+ ) -> str:
250
+ """Analyze what changed in a completed ticket (deterministic, 0 LLM calls).
251
+
252
+ This tool parses git diffs and evidence to understand changes WITHOUT
253
+ calling an LLM. It extracts file counts, line changes, and verification
254
+ status using deterministic text parsing.
255
+
256
+ Args:
257
+ db: Database session
258
+ ticket_id: Ticket ID to analyze
259
+
260
+ Returns:
261
+ JSON string with change analysis:
262
+ {
263
+ "ticket_id": "abc-123",
264
+ "ticket_title": "Add authentication",
265
+ "state": "done",
266
+ "files_changed": ["backend/app/auth.py", "backend/app/models.py"],
267
+ "file_count": 2,
268
+ "lines_added": 150,
269
+ "lines_deleted": 20,
270
+ "verification_passed": true,
271
+ "has_revision": true
272
+ }
273
+ """
274
+ try:
275
+ from sqlalchemy import select
276
+ from sqlalchemy.orm import selectinload
277
+
278
+ from app.models.ticket import Ticket
279
+
280
+ # Get ticket with revision
281
+ stmt = (
282
+ select(Ticket)
283
+ .where(Ticket.id == ticket_id)
284
+ .options(selectinload(Ticket.revisions))
285
+ )
286
+ result = await db.execute(stmt)
287
+ ticket = result.scalar_one_or_none()
288
+
289
+ if not ticket:
290
+ return json.dumps(
291
+ {
292
+ "error": f"Ticket {ticket_id} not found",
293
+ "ticket_id": ticket_id,
294
+ }
295
+ )
296
+
297
+ # Get latest revision
298
+ revisions = sorted(ticket.revisions, key=lambda r: r.number, reverse=True)
299
+ latest_revision = revisions[0] if revisions else None
300
+
301
+ if not latest_revision:
302
+ return json.dumps(
303
+ {
304
+ "ticket_id": ticket_id,
305
+ "ticket_title": ticket.title,
306
+ "state": ticket.state,
307
+ "files_changed": [],
308
+ "file_count": 0,
309
+ "lines_added": 0,
310
+ "lines_deleted": 0,
311
+ "verification_passed": False,
312
+ "has_revision": False,
313
+ }
314
+ )
315
+
316
+ # Load diff stat from evidence (deterministic)
317
+ files_changed = []
318
+ lines_added = 0
319
+ lines_deleted = 0
320
+
321
+ if latest_revision.diff_stat_evidence_id:
322
+ from app.models.evidence import Evidence
323
+
324
+ evidence = await db.get(Evidence, latest_revision.diff_stat_evidence_id)
325
+ diff_stat_content = None
326
+ if evidence and evidence.stdout_path:
327
+ try:
328
+ from pathlib import Path
329
+
330
+ diff_stat_content = Path(evidence.stdout_path).read_text()
331
+ except Exception:
332
+ pass
333
+
334
+ if diff_stat_content:
335
+ # Parse diff stat format: "file.py | 10 +++++-----"
336
+ for line in diff_stat_content.split("\n"):
337
+ if "|" in line:
338
+ file_part = line.split("|")[0].strip()
339
+ if file_part:
340
+ files_changed.append(file_part)
341
+
342
+ plus_count = line.count("+")
343
+ minus_count = line.count("-")
344
+ lines_added += plus_count
345
+ lines_deleted += minus_count
346
+
347
+ # Check verification status
348
+ verification_passed = latest_revision.status == "approved"
349
+
350
+ result_data = {
351
+ "ticket_id": ticket_id,
352
+ "ticket_title": ticket.title,
353
+ "state": ticket.state,
354
+ "files_changed": files_changed[:20], # Cap at 20 for prompt size
355
+ "file_count": len(files_changed),
356
+ "lines_added": lines_added,
357
+ "lines_deleted": lines_deleted,
358
+ "verification_passed": verification_passed,
359
+ "has_revision": True,
360
+ }
361
+
362
+ return json.dumps(result_data, indent=2)
363
+
364
+ except Exception as e:
365
+ return json.dumps(
366
+ {
367
+ "error": str(e),
368
+ "ticket_id": ticket_id,
369
+ }
370
+ )
371
+
372
+
373
+ # Export tools for easy import
374
+ __all__ = [
375
+ "analyze_codebase",
376
+ "search_tickets",
377
+ "get_goal_context",
378
+ "analyze_ticket_changes",
379
+ ]
@@ -0,0 +1,98 @@
1
+ """Authentication service: password hashing, JWT tokens, user CRUD."""
2
+
3
+ import logging
4
+ import os
5
+ import uuid
6
+ from datetime import UTC, datetime, timedelta
7
+
8
+ import bcrypt
9
+ from jose import JWTError, jwt
10
+ from sqlalchemy import select
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+
13
+ from app.models.user import User
14
+
15
+ _logger = logging.getLogger(__name__)
16
+
17
+ # Configuration
18
+ _DEFAULT_SECRET = "draft-dev-secret-change-in-production"
19
+ SECRET_KEY = os.getenv("AUTH_SECRET_KEY", _DEFAULT_SECRET)
20
+ if SECRET_KEY == _DEFAULT_SECRET:
21
+ _logger.warning(
22
+ "AUTH_SECRET_KEY is not set — using insecure default. "
23
+ "Set AUTH_SECRET_KEY in your environment for production use."
24
+ )
25
+ ALGORITHM = "HS256"
26
+ ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("AUTH_TOKEN_EXPIRE_MINUTES", "1440")) # 24h
27
+
28
+
29
+ def hash_password(password: str) -> str:
30
+ """Hash a plaintext password using bcrypt."""
31
+ return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
32
+
33
+
34
+ def verify_password(plain: str, hashed: str) -> bool:
35
+ """Verify a plaintext password against a bcrypt hash."""
36
+ return bcrypt.checkpw(plain.encode(), hashed.encode())
37
+
38
+
39
+ def create_access_token(user_id: str, email: str) -> str:
40
+ """Create a JWT access token for a user."""
41
+ expire = datetime.now(UTC) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
42
+ payload = {
43
+ "sub": user_id,
44
+ "email": email,
45
+ "exp": expire,
46
+ "iat": datetime.now(UTC),
47
+ }
48
+ return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
49
+
50
+
51
+ def decode_access_token(token: str) -> dict | None:
52
+ """Decode and validate a JWT token. Returns payload or None."""
53
+ try:
54
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
55
+ if payload.get("sub") is None:
56
+ return None
57
+ return payload
58
+ except JWTError:
59
+ return None
60
+
61
+
62
+ async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
63
+ """Look up a user by email."""
64
+ result = await db.execute(select(User).where(User.email == email))
65
+ return result.scalar_one_or_none()
66
+
67
+
68
+ async def get_user_by_id(db: AsyncSession, user_id: str) -> User | None:
69
+ """Look up a user by ID."""
70
+ result = await db.execute(select(User).where(User.id == user_id))
71
+ return result.scalar_one_or_none()
72
+
73
+
74
+ async def create_user(
75
+ db: AsyncSession, email: str, password: str, display_name: str
76
+ ) -> User:
77
+ """Create a new user account. Caller must handle duplicate email checks."""
78
+ user = User(
79
+ id=str(uuid.uuid4()),
80
+ email=email.lower().strip(),
81
+ display_name=display_name.strip(),
82
+ hashed_password=hash_password(password),
83
+ )
84
+ db.add(user)
85
+ await db.flush()
86
+ return user
87
+
88
+
89
+ async def authenticate_user(db: AsyncSession, email: str, password: str) -> User | None:
90
+ """Authenticate a user by email and password. Returns User or None."""
91
+ user = await get_user_by_email(db, email.lower().strip())
92
+ if user is None:
93
+ return None
94
+ if not user.is_active:
95
+ return None
96
+ if not verify_password(password, user.hashed_password):
97
+ return None
98
+ return user