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,382 @@
1
+ """Pull Request router for GitHub integration."""
2
+
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+
6
+ from fastapi import APIRouter, Depends, HTTPException
7
+ from pydantic import BaseModel
8
+ from sqlalchemy import select
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from app.database import get_db
12
+ from app.exceptions import ConfigurationError
13
+ from app.models.ticket import Ticket
14
+ from app.models.ticket_event import TicketEvent
15
+ from app.models.workspace import Workspace
16
+ from app.services.git_host import get_git_host_provider
17
+ from app.state_machine import ActorType, EventType, TicketState, validate_transition
18
+
19
+ router = APIRouter(prefix="/pull-requests", tags=["pull-requests"])
20
+
21
+
22
+ class CreatePRRequest(BaseModel):
23
+ """Request to create a GitHub Pull Request."""
24
+
25
+ ticket_id: str
26
+ title: str | None = None
27
+ body: str | None = None
28
+ base_branch: str = "main"
29
+
30
+
31
+ class PRStatusResponse(BaseModel):
32
+ """Response with PR status information."""
33
+
34
+ pr_number: int
35
+ pr_url: str
36
+ pr_state: str
37
+ pr_created_at: datetime | None
38
+ pr_merged_at: datetime | None
39
+ pr_head_branch: str | None
40
+ pr_base_branch: str | None
41
+
42
+
43
+ class AddPRCommentRequest(BaseModel):
44
+ """Request to add a comment to a PR."""
45
+
46
+ body: str
47
+
48
+
49
+ class PRCommentResponse(BaseModel):
50
+ """A single PR comment."""
51
+
52
+ author: str
53
+ body: str
54
+ created_at: str
55
+
56
+
57
+ class MergePRRequest(BaseModel):
58
+ """Request to merge a PR."""
59
+
60
+ strategy: str = "squash" # squash, merge, rebase
61
+
62
+
63
+ @router.post("", response_model=PRStatusResponse)
64
+ async def create_pull_request(
65
+ request: CreatePRRequest,
66
+ db: AsyncSession = Depends(get_db),
67
+ ):
68
+ """
69
+ Create a GitHub Pull Request for a ticket.
70
+
71
+ This will:
72
+ 1. Get the ticket and its workspace
73
+ 2. Push the workspace branch to remote
74
+ 3. Create a PR using GitHub CLI
75
+ 4. Update ticket with PR information
76
+ 5. Transition ticket to REVIEW state
77
+ """
78
+ # Get ticket
79
+ result = await db.execute(select(Ticket).where(Ticket.id == request.ticket_id))
80
+ ticket = result.scalar_one_or_none()
81
+
82
+ if not ticket:
83
+ raise HTTPException(
84
+ status_code=404, detail=f"Ticket {request.ticket_id} not found"
85
+ )
86
+
87
+ # Check if PR already exists
88
+ if ticket.pr_number:
89
+ raise HTTPException(
90
+ status_code=400,
91
+ detail=f"Ticket already has PR #{ticket.pr_number}: {ticket.pr_url}",
92
+ )
93
+
94
+ # Get workspace
95
+ result = await db.execute(
96
+ select(Workspace).where(Workspace.ticket_id == request.ticket_id)
97
+ )
98
+ workspace = result.scalar_one_or_none()
99
+
100
+ if not workspace:
101
+ raise HTTPException(
102
+ status_code=400,
103
+ detail=f"Ticket {request.ticket_id} has no workspace. Cannot create PR.",
104
+ )
105
+
106
+ if not workspace.worktree_path:
107
+ raise HTTPException(
108
+ status_code=400,
109
+ detail=f"Workspace for ticket {request.ticket_id} has no worktree path.",
110
+ )
111
+
112
+ repo_path = Path(workspace.worktree_path)
113
+
114
+ if not repo_path.exists():
115
+ raise HTTPException(
116
+ status_code=400,
117
+ detail=f"Workspace path does not exist: {workspace.worktree_path}",
118
+ )
119
+
120
+ # Determine branch name
121
+ head_branch = workspace.branch_name or f"ticket-{ticket.id[:8]}"
122
+
123
+ # Use provided title/body or generate defaults
124
+ pr_title = request.title or ticket.title
125
+ pr_body = request.body or (
126
+ f"Implements: {ticket.title}\n\n"
127
+ f"{ticket.description or ''}\n\n"
128
+ f"Ticket ID: {ticket.id}"
129
+ )
130
+
131
+ # Get git host provider (auto-detects GitHub vs GitLab)
132
+ git_host = get_git_host_provider(repo_path)
133
+
134
+ try:
135
+ # Check if authenticated
136
+ await git_host.ensure_authenticated()
137
+
138
+ # Create PR/MR
139
+ pr = await git_host.create_pr(
140
+ repo_path=repo_path,
141
+ title=pr_title,
142
+ body=pr_body,
143
+ head_branch=head_branch,
144
+ base_branch=request.base_branch,
145
+ )
146
+
147
+ # Update ticket with PR information
148
+ ticket.pr_number = pr.number
149
+ ticket.pr_url = pr.url
150
+ ticket.pr_state = pr.state
151
+ ticket.pr_created_at = datetime.now()
152
+ ticket.pr_head_branch = pr.head_branch
153
+ ticket.pr_base_branch = pr.base_branch
154
+
155
+ await db.commit()
156
+ await db.refresh(ticket)
157
+
158
+ return PRStatusResponse(
159
+ pr_number=ticket.pr_number,
160
+ pr_url=ticket.pr_url,
161
+ pr_state=ticket.pr_state,
162
+ pr_created_at=ticket.pr_created_at,
163
+ pr_merged_at=ticket.pr_merged_at,
164
+ pr_head_branch=ticket.pr_head_branch,
165
+ pr_base_branch=ticket.pr_base_branch,
166
+ )
167
+
168
+ except ConfigurationError as e:
169
+ raise HTTPException(status_code=400, detail=str(e))
170
+ except RuntimeError as e:
171
+ raise HTTPException(status_code=500, detail=f"Failed to create PR: {str(e)}")
172
+ except Exception as e:
173
+ raise HTTPException(status_code=500, detail=f"Unexpected error: {str(e)}")
174
+
175
+
176
+ @router.get("/{ticket_id}", response_model=PRStatusResponse)
177
+ async def get_pr_status(
178
+ ticket_id: str,
179
+ db: AsyncSession = Depends(get_db),
180
+ ):
181
+ """Get the PR status for a ticket."""
182
+ result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
183
+ ticket = result.scalar_one_or_none()
184
+
185
+ if not ticket:
186
+ raise HTTPException(status_code=404, detail=f"Ticket {ticket_id} not found")
187
+
188
+ if not ticket.pr_number:
189
+ raise HTTPException(
190
+ status_code=404, detail=f"Ticket {ticket_id} has no associated PR"
191
+ )
192
+
193
+ return PRStatusResponse(
194
+ pr_number=ticket.pr_number,
195
+ pr_url=ticket.pr_url,
196
+ pr_state=ticket.pr_state,
197
+ pr_created_at=ticket.pr_created_at,
198
+ pr_merged_at=ticket.pr_merged_at,
199
+ pr_head_branch=ticket.pr_head_branch,
200
+ pr_base_branch=ticket.pr_base_branch,
201
+ )
202
+
203
+
204
+ @router.post("/{ticket_id}/refresh", response_model=PRStatusResponse)
205
+ async def refresh_pr_status(
206
+ ticket_id: str,
207
+ db: AsyncSession = Depends(get_db),
208
+ ):
209
+ """
210
+ Manually refresh the PR status from GitHub.
211
+
212
+ This will fetch the latest PR state from GitHub and update the ticket.
213
+ """
214
+ result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
215
+ ticket = result.scalar_one_or_none()
216
+
217
+ if not ticket:
218
+ raise HTTPException(status_code=404, detail=f"Ticket {ticket_id} not found")
219
+
220
+ if not ticket.pr_number:
221
+ raise HTTPException(
222
+ status_code=404, detail=f"Ticket {ticket_id} has no associated PR"
223
+ )
224
+
225
+ # Get workspace for repo path
226
+ result = await db.execute(select(Workspace).where(Workspace.ticket_id == ticket_id))
227
+ workspace = result.scalar_one_or_none()
228
+
229
+ if not workspace or not workspace.worktree_path:
230
+ raise HTTPException(
231
+ status_code=400,
232
+ detail="Cannot refresh PR status: workspace not found or no worktree path",
233
+ )
234
+
235
+ repo_path = Path(workspace.worktree_path)
236
+
237
+ try:
238
+ git_host = get_git_host_provider(repo_path)
239
+ pr_details = await git_host.get_pr_details(repo_path, ticket.pr_number)
240
+
241
+ # Update ticket
242
+ old_state = ticket.pr_state
243
+ ticket.pr_state = pr_details["state"]
244
+
245
+ if pr_details.get("merged") and not ticket.pr_merged_at:
246
+ ticket.pr_merged_at = datetime.now()
247
+
248
+ # Auto-transition ticket if PR was merged
249
+ if pr_details.get("merged") and old_state != "MERGED":
250
+ ticket.pr_state = "MERGED"
251
+ current_state = TicketState(ticket.state)
252
+ if validate_transition(current_state, TicketState.DONE):
253
+ ticket.state = TicketState.DONE.value
254
+ event = TicketEvent(
255
+ ticket_id=ticket.id,
256
+ event_type=EventType.TRANSITIONED.value,
257
+ from_state=current_state.value,
258
+ to_state=TicketState.DONE.value,
259
+ actor_type=ActorType.SYSTEM.value,
260
+ actor_id="pr_refresh",
261
+ reason="PR merged on remote",
262
+ )
263
+ db.add(event)
264
+
265
+ await db.commit()
266
+ await db.refresh(ticket)
267
+
268
+ return PRStatusResponse(
269
+ pr_number=ticket.pr_number,
270
+ pr_url=ticket.pr_url,
271
+ pr_state=ticket.pr_state,
272
+ pr_created_at=ticket.pr_created_at,
273
+ pr_merged_at=ticket.pr_merged_at,
274
+ pr_head_branch=ticket.pr_head_branch,
275
+ pr_base_branch=ticket.pr_base_branch,
276
+ )
277
+
278
+ except Exception as e:
279
+ raise HTTPException(
280
+ status_code=500, detail=f"Failed to refresh PR status: {str(e)}"
281
+ )
282
+
283
+
284
+ # ===================== PR Comment Endpoints =====================
285
+
286
+
287
+ async def _get_ticket_with_pr(ticket_id: str, db: AsyncSession) -> tuple:
288
+ """Get ticket with PR info and workspace repo path."""
289
+ result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
290
+ ticket = result.scalar_one_or_none()
291
+ if not ticket:
292
+ raise HTTPException(status_code=404, detail=f"Ticket {ticket_id} not found")
293
+ if not ticket.pr_number:
294
+ raise HTTPException(status_code=400, detail="Ticket has no associated PR")
295
+
296
+ result = await db.execute(select(Workspace).where(Workspace.ticket_id == ticket_id))
297
+ workspace = result.scalar_one_or_none()
298
+ if not workspace or not workspace.worktree_path:
299
+ raise HTTPException(status_code=400, detail="No workspace found for ticket")
300
+
301
+ repo_path = Path(workspace.worktree_path)
302
+ return ticket, repo_path
303
+
304
+
305
+ @router.post("/{ticket_id}/comments", response_model=dict)
306
+ async def add_pr_comment(
307
+ ticket_id: str,
308
+ request: AddPRCommentRequest,
309
+ db: AsyncSession = Depends(get_db),
310
+ ):
311
+ """Add a comment to a ticket's PR."""
312
+ ticket, repo_path = await _get_ticket_with_pr(ticket_id, db)
313
+ git_host = get_git_host_provider(repo_path)
314
+
315
+ try:
316
+ result = await git_host.add_pr_comment(
317
+ repo_path, ticket.pr_number, request.body
318
+ )
319
+ return result
320
+ except Exception as e:
321
+ raise HTTPException(status_code=500, detail=f"Failed to add comment: {str(e)}")
322
+
323
+
324
+ @router.get("/{ticket_id}/comments", response_model=list[PRCommentResponse])
325
+ async def list_pr_comments(
326
+ ticket_id: str,
327
+ db: AsyncSession = Depends(get_db),
328
+ ):
329
+ """List all comments on a ticket's PR."""
330
+ ticket, repo_path = await _get_ticket_with_pr(ticket_id, db)
331
+ git_host = get_git_host_provider(repo_path)
332
+
333
+ try:
334
+ comments = await git_host.list_pr_comments(repo_path, ticket.pr_number)
335
+ return [
336
+ PRCommentResponse(
337
+ author=c.get("author", {}).get("login", "unknown"),
338
+ body=c.get("body", ""),
339
+ created_at=c.get("createdAt", ""),
340
+ )
341
+ for c in comments
342
+ ]
343
+ except Exception as e:
344
+ raise HTTPException(
345
+ status_code=500, detail=f"Failed to list comments: {str(e)}"
346
+ )
347
+
348
+
349
+ @router.post("/{ticket_id}/merge", response_model=dict)
350
+ async def merge_pr_endpoint(
351
+ ticket_id: str,
352
+ request: MergePRRequest,
353
+ db: AsyncSession = Depends(get_db),
354
+ ):
355
+ """Merge a ticket's PR on GitHub with the given strategy."""
356
+ ticket, repo_path = await _get_ticket_with_pr(ticket_id, db)
357
+ git_host = get_git_host_provider(repo_path)
358
+
359
+ try:
360
+ result = await git_host.merge_pr(repo_path, ticket.pr_number, request.strategy)
361
+
362
+ # Update ticket state on successful merge
363
+ ticket.pr_state = "MERGED"
364
+ ticket.pr_merged_at = datetime.now()
365
+ current_state = TicketState(ticket.state)
366
+ if validate_transition(current_state, TicketState.DONE):
367
+ ticket.state = TicketState.DONE.value
368
+ event = TicketEvent(
369
+ ticket_id=ticket.id,
370
+ event_type=EventType.TRANSITIONED.value,
371
+ from_state=current_state.value,
372
+ to_state=TicketState.DONE.value,
373
+ actor_type=ActorType.SYSTEM.value,
374
+ actor_id="pr_merge",
375
+ reason="PR merged",
376
+ )
377
+ db.add(event)
378
+ await db.commit()
379
+
380
+ return result
381
+ except Exception as e:
382
+ raise HTTPException(status_code=500, detail=f"Failed to merge PR: {str(e)}")
@@ -0,0 +1,263 @@
1
+ """API router for Repository endpoints."""
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, status
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ from app.database import get_db
7
+ from app.schemas.repo import (
8
+ DiscoveredRepoResponse,
9
+ DiscoverReposRequest,
10
+ DiscoverReposResponse,
11
+ RepoCreate,
12
+ RepoListResponse,
13
+ RepoResponse,
14
+ RepoUpdate,
15
+ ValidateRepoRequest,
16
+ ValidateRepoResponse,
17
+ )
18
+ from app.services.repo_discovery_service import RepoDiscoveryService
19
+
20
+ router = APIRouter(prefix="/repos", tags=["repos"])
21
+
22
+
23
+ # ============================================================================
24
+ # Repo CRUD endpoints
25
+ # ============================================================================
26
+
27
+
28
+ @router.post(
29
+ "",
30
+ response_model=RepoResponse,
31
+ status_code=status.HTTP_201_CREATED,
32
+ summary="Register a new repository",
33
+ )
34
+ async def create_repo(
35
+ data: RepoCreate,
36
+ db: AsyncSession = Depends(get_db),
37
+ ) -> RepoResponse:
38
+ """
39
+ Register a new git repository in the global registry.
40
+
41
+ **Validation:**
42
+ - Path must exist and be a directory
43
+ - Path must be a valid git repository
44
+ - Path must not already be registered
45
+
46
+ **Metadata:**
47
+ - Repository name derived from path if not provided
48
+ - Git metadata (default branch, remote URL) auto-detected
49
+ """
50
+ service = RepoDiscoveryService(db)
51
+ try:
52
+ repo = await service.register_repo(
53
+ path=data.path,
54
+ display_name=data.display_name,
55
+ setup_script=data.setup_script,
56
+ cleanup_script=data.cleanup_script,
57
+ dev_server_script=data.dev_server_script,
58
+ )
59
+ return RepoResponse.model_validate(repo)
60
+ except ValueError as e:
61
+ raise HTTPException(status_code=400, detail=str(e))
62
+
63
+
64
+ @router.get(
65
+ "",
66
+ response_model=RepoListResponse,
67
+ summary="List all registered repositories",
68
+ )
69
+ async def list_repos(
70
+ db: AsyncSession = Depends(get_db),
71
+ ) -> RepoListResponse:
72
+ """Get all registered repositories, ordered by creation date (newest first)."""
73
+ service = RepoDiscoveryService(db)
74
+ repos = await service.get_all_repos()
75
+ return RepoListResponse(
76
+ repos=[RepoResponse.model_validate(r) for r in repos],
77
+ total=len(repos),
78
+ )
79
+
80
+
81
+ @router.get(
82
+ "/{repo_id}",
83
+ response_model=RepoResponse,
84
+ summary="Get a repository by ID",
85
+ )
86
+ async def get_repo(
87
+ repo_id: str,
88
+ db: AsyncSession = Depends(get_db),
89
+ ) -> RepoResponse:
90
+ """Get a repository by its ID."""
91
+ service = RepoDiscoveryService(db)
92
+ repo = await service.get_repo_by_id(repo_id)
93
+ if not repo:
94
+ raise HTTPException(status_code=404, detail=f"Repo not found: {repo_id}")
95
+ return RepoResponse.model_validate(repo)
96
+
97
+
98
+ @router.patch(
99
+ "/{repo_id}",
100
+ response_model=RepoResponse,
101
+ summary="Update a repository",
102
+ )
103
+ async def update_repo(
104
+ repo_id: str,
105
+ data: RepoUpdate,
106
+ db: AsyncSession = Depends(get_db),
107
+ ) -> RepoResponse:
108
+ """
109
+ Update a repository's configuration.
110
+
111
+ **Updatable fields:**
112
+ - display_name - User-friendly name
113
+ - setup_script - Optional setup script
114
+ - cleanup_script - Optional cleanup script
115
+ - dev_server_script - Optional dev server script
116
+
117
+ **Note:** Path and git metadata are read-only.
118
+ """
119
+ service = RepoDiscoveryService(db)
120
+ try:
121
+ repo = await service.update_repo(
122
+ repo_id=repo_id,
123
+ display_name=data.display_name,
124
+ setup_script=data.setup_script,
125
+ cleanup_script=data.cleanup_script,
126
+ dev_server_script=data.dev_server_script,
127
+ )
128
+ return RepoResponse.model_validate(repo)
129
+ except ValueError as e:
130
+ raise HTTPException(status_code=404, detail=str(e))
131
+
132
+
133
+ @router.delete(
134
+ "/{repo_id}",
135
+ status_code=status.HTTP_204_NO_CONTENT,
136
+ summary="Delete a repository",
137
+ )
138
+ async def delete_repo(
139
+ repo_id: str,
140
+ db: AsyncSession = Depends(get_db),
141
+ ) -> None:
142
+ """
143
+ Delete a repository from the global registry.
144
+
145
+ **Warning:** This will cascade delete all BoardRepo associations.
146
+ Boards using this repo will no longer have it available.
147
+ """
148
+ service = RepoDiscoveryService(db)
149
+ try:
150
+ await service.delete_repo(repo_id)
151
+ except ValueError as e:
152
+ raise HTTPException(status_code=404, detail=str(e))
153
+
154
+
155
+ # ============================================================================
156
+ # Discovery endpoints
157
+ # ============================================================================
158
+
159
+
160
+ @router.post(
161
+ "/discover",
162
+ response_model=DiscoverReposResponse,
163
+ summary="Discover git repositories",
164
+ )
165
+ async def discover_repos(
166
+ request: DiscoverReposRequest,
167
+ db: AsyncSession = Depends(get_db),
168
+ ) -> DiscoverReposResponse:
169
+ """
170
+ Scan directories for git repositories.
171
+
172
+ **How it works:**
173
+ 1. Walks directory tree up to `max_depth` levels
174
+ 2. Excludes common non-repo directories (node_modules, venv, etc.)
175
+ 3. Detects .git directories
176
+ 4. Extracts git metadata (branch, remote)
177
+ 5. Returns list of discovered repos (not yet registered)
178
+
179
+ **Example:**
180
+ ```json
181
+ {
182
+ "search_paths": ["~/code", "~/projects"],
183
+ "max_depth": 3,
184
+ "exclude_patterns": ["archive", "old"]
185
+ }
186
+ ```
187
+
188
+ **Next steps:**
189
+ - Review discovered repos
190
+ - Use POST /repos to register desired repos
191
+ """
192
+ service = RepoDiscoveryService(db)
193
+
194
+ exclude_set = set(request.exclude_patterns) if request.exclude_patterns else None
195
+
196
+ discovered = await service.discover_repos(
197
+ search_paths=request.search_paths,
198
+ max_depth=request.max_depth,
199
+ exclude_patterns=exclude_set,
200
+ )
201
+
202
+ return DiscoverReposResponse(
203
+ discovered=[
204
+ DiscoveredRepoResponse(
205
+ path=r.path,
206
+ name=r.name,
207
+ display_name=r.display_name,
208
+ default_branch=r.default_branch,
209
+ remote_url=r.remote_url,
210
+ is_valid=r.is_valid,
211
+ error_message=r.error_message,
212
+ )
213
+ for r in discovered
214
+ ],
215
+ total=len(discovered),
216
+ )
217
+
218
+
219
+ @router.post(
220
+ "/validate",
221
+ response_model=ValidateRepoResponse,
222
+ summary="Validate a repository path",
223
+ )
224
+ async def validate_repo(
225
+ request: ValidateRepoRequest,
226
+ db: AsyncSession = Depends(get_db),
227
+ ) -> ValidateRepoResponse:
228
+ """
229
+ Validate that a path is a valid git repository.
230
+
231
+ **Checks:**
232
+ - Path exists
233
+ - Path is a directory
234
+ - Path contains a .git directory
235
+ - Git repository is accessible
236
+
237
+ **Returns:**
238
+ - is_valid: Whether path is a valid repo
239
+ - path: Normalized absolute path
240
+ - metadata: Git metadata if valid
241
+ - error_message: Error description if invalid
242
+ """
243
+ service = RepoDiscoveryService(db)
244
+ validation = await service.validate_repo_path(request.path)
245
+
246
+ metadata_response = None
247
+ if validation.metadata:
248
+ metadata_response = DiscoveredRepoResponse(
249
+ path=validation.metadata.path,
250
+ name=validation.metadata.name,
251
+ display_name=validation.metadata.display_name,
252
+ default_branch=validation.metadata.default_branch,
253
+ remote_url=validation.metadata.remote_url,
254
+ is_valid=validation.metadata.is_valid,
255
+ error_message=validation.metadata.error_message,
256
+ )
257
+
258
+ return ValidateRepoResponse(
259
+ is_valid=validation.is_valid,
260
+ path=validation.path,
261
+ error_message=validation.error_message,
262
+ metadata=metadata_response,
263
+ )