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,87 @@
1
+ """Factory for creating git host providers with auto-detection."""
2
+
3
+ import logging
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ from app.services.git_host.github import GitHubProvider
8
+ from app.services.git_host.gitlab import GitLabProvider
9
+ from app.services.git_host.protocol import GitHostProvider
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Cached providers by repo path
14
+ _providers: dict[str, GitHostProvider] = {}
15
+
16
+
17
+ def detect_git_host(repo_path: Path | None = None) -> str:
18
+ """Detect the git host from the remote URL.
19
+
20
+ Args:
21
+ repo_path: Path to a git repository. If None, uses cwd.
22
+
23
+ Returns:
24
+ 'github', 'gitlab', or 'unknown'
25
+ """
26
+ try:
27
+ result = subprocess.run(
28
+ ["git", "remote", "get-url", "origin"],
29
+ cwd=repo_path or ".",
30
+ capture_output=True,
31
+ text=True,
32
+ timeout=5,
33
+ )
34
+ if result.returncode != 0:
35
+ return "unknown"
36
+
37
+ url = result.stdout.strip().lower()
38
+
39
+ if "github.com" in url or "github" in url:
40
+ return "github"
41
+ elif "gitlab.com" in url or "gitlab" in url:
42
+ return "gitlab"
43
+ else:
44
+ return "unknown"
45
+
46
+ except Exception as e:
47
+ logger.debug(f"Failed to detect git host: {e}")
48
+ return "unknown"
49
+
50
+
51
+ def get_git_host_provider(
52
+ repo_path: Path | None = None,
53
+ force_provider: str | None = None,
54
+ ) -> GitHostProvider:
55
+ """Get a git host provider, auto-detecting from the remote URL.
56
+
57
+ Args:
58
+ repo_path: Path to a git repository for detection.
59
+ force_provider: Force a specific provider ('github' or 'gitlab').
60
+
61
+ Returns:
62
+ A GitHostProvider instance.
63
+
64
+ Raises:
65
+ ValueError: If the host cannot be determined or is unsupported.
66
+ """
67
+ cache_key = force_provider or str(repo_path or "default")
68
+
69
+ if cache_key in _providers:
70
+ return _providers[cache_key]
71
+
72
+ host = force_provider or detect_git_host(repo_path)
73
+
74
+ if host == "github":
75
+ provider = GitHubProvider()
76
+ elif host == "gitlab":
77
+ provider = GitLabProvider()
78
+ else:
79
+ # Default to GitHub (most common) but log a warning
80
+ logger.warning(
81
+ f"Could not detect git host (got '{host}'), defaulting to GitHub. "
82
+ "Set force_provider='gitlab' if using GitLab."
83
+ )
84
+ provider = GitHubProvider()
85
+
86
+ _providers[cache_key] = provider
87
+ return provider
@@ -0,0 +1,270 @@
1
+ """GitHub provider implementation using gh CLI."""
2
+
3
+ import json
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ from app.exceptions import ConfigurationError
10
+ from app.services.git_host.protocol import PullRequest
11
+
12
+
13
+ class GitHubProvider:
14
+ """GitHub provider using the gh CLI."""
15
+
16
+ def __init__(self) -> None:
17
+ self._gh_path: str | None = None
18
+ self._authenticated: bool | None = None
19
+
20
+ @property
21
+ def name(self) -> str:
22
+ return "github"
23
+
24
+ @property
25
+ def gh_path(self) -> str:
26
+ if self._gh_path is None:
27
+ self._gh_path = shutil.which("gh")
28
+ if not self._gh_path:
29
+ raise ConfigurationError(
30
+ "GitHub CLI (gh) not found. Install from https://cli.github.com/"
31
+ )
32
+ return self._gh_path
33
+
34
+ def is_available(self) -> bool:
35
+ try:
36
+ return bool(shutil.which("gh"))
37
+ except Exception:
38
+ return False
39
+
40
+ async def is_authenticated(self) -> bool:
41
+ if self._authenticated is not None:
42
+ return self._authenticated
43
+ try:
44
+ result = subprocess.run(
45
+ [self.gh_path, "auth", "status"],
46
+ capture_output=True,
47
+ text=True,
48
+ timeout=5,
49
+ )
50
+ self._authenticated = result.returncode == 0
51
+ return self._authenticated
52
+ except Exception:
53
+ self._authenticated = False
54
+ return False
55
+
56
+ async def ensure_authenticated(self) -> None:
57
+ if not await self.is_authenticated():
58
+ raise ConfigurationError(
59
+ "Not authenticated with GitHub. Run 'gh auth login' first."
60
+ )
61
+
62
+ async def create_pr(
63
+ self,
64
+ repo_path: Path,
65
+ title: str,
66
+ body: str,
67
+ head_branch: str,
68
+ base_branch: str,
69
+ ) -> PullRequest:
70
+ await self.ensure_authenticated()
71
+
72
+ # Push branch to remote first (required for PR creation)
73
+ from app.services.git_ops import push_branch
74
+
75
+ push_result = push_branch(repo_path, head_branch)
76
+ if not push_result.success:
77
+ raise RuntimeError(
78
+ f"Failed to push branch before PR creation: {push_result.message}"
79
+ )
80
+
81
+ cmd = [
82
+ self.gh_path,
83
+ "pr",
84
+ "create",
85
+ "--title",
86
+ title,
87
+ "--body",
88
+ body,
89
+ "--base",
90
+ base_branch,
91
+ "--head",
92
+ head_branch,
93
+ ]
94
+
95
+ try:
96
+ result = subprocess.run(
97
+ cmd, cwd=repo_path, capture_output=True, text=True, timeout=30
98
+ )
99
+
100
+ if result.returncode != 0:
101
+ raise RuntimeError(f"Failed to create PR: {result.stderr.strip()}")
102
+
103
+ pr_url = result.stdout.strip()
104
+ pr_number_match = re.search(r"/pull/(\d+)", pr_url)
105
+ if not pr_number_match:
106
+ raise RuntimeError(f"Could not extract PR number from URL: {pr_url}")
107
+
108
+ return PullRequest(
109
+ number=int(pr_number_match.group(1)),
110
+ url=pr_url,
111
+ title=title,
112
+ state="OPEN",
113
+ head_branch=head_branch,
114
+ base_branch=base_branch,
115
+ merged=False,
116
+ )
117
+
118
+ except subprocess.TimeoutExpired:
119
+ raise RuntimeError("PR creation timed out after 30 seconds")
120
+ except RuntimeError:
121
+ raise
122
+ except Exception as e:
123
+ raise RuntimeError(f"Failed to create PR: {e}")
124
+
125
+ async def get_pr_status(self, repo_path: Path, pr_number: int) -> str:
126
+ await self.ensure_authenticated()
127
+
128
+ cmd = [
129
+ self.gh_path,
130
+ "pr",
131
+ "view",
132
+ str(pr_number),
133
+ "--json",
134
+ "state",
135
+ "--jq",
136
+ ".state",
137
+ ]
138
+
139
+ try:
140
+ result = subprocess.run(
141
+ cmd, cwd=repo_path, capture_output=True, text=True, timeout=10
142
+ )
143
+ if result.returncode != 0:
144
+ raise RuntimeError(f"Failed to get PR status: {result.stderr}")
145
+ return result.stdout.strip()
146
+ except Exception as e:
147
+ raise RuntimeError(f"Failed to get PR status: {e}")
148
+
149
+ async def get_pr_details(self, repo_path: Path, pr_number: int) -> dict[str, any]:
150
+ await self.ensure_authenticated()
151
+
152
+ cmd = [
153
+ self.gh_path,
154
+ "pr",
155
+ "view",
156
+ str(pr_number),
157
+ "--json",
158
+ "number,title,state,url,headRefName,baseRefName,merged",
159
+ ]
160
+
161
+ try:
162
+ result = subprocess.run(
163
+ cmd, cwd=repo_path, capture_output=True, text=True, timeout=10
164
+ )
165
+ if result.returncode != 0:
166
+ raise RuntimeError(f"Failed to get PR details: {result.stderr}")
167
+ return json.loads(result.stdout)
168
+ except Exception as e:
169
+ raise RuntimeError(f"Failed to get PR details: {e}")
170
+
171
+ async def add_pr_comment(self, repo_path: Path, pr_number: int, body: str) -> dict:
172
+ """Add a comment to a PR."""
173
+ await self.ensure_authenticated()
174
+
175
+ cmd = [
176
+ self.gh_path,
177
+ "pr",
178
+ "comment",
179
+ str(pr_number),
180
+ "--body",
181
+ body,
182
+ ]
183
+
184
+ try:
185
+ result = subprocess.run(
186
+ cmd, cwd=repo_path, capture_output=True, text=True, timeout=15
187
+ )
188
+ if result.returncode != 0:
189
+ raise RuntimeError(f"Failed to add PR comment: {result.stderr.strip()}")
190
+ return {"success": True, "message": "Comment added"}
191
+ except subprocess.TimeoutExpired:
192
+ raise RuntimeError("PR comment timed out")
193
+ except RuntimeError:
194
+ raise
195
+ except Exception as e:
196
+ raise RuntimeError(f"Failed to add PR comment: {e}")
197
+
198
+ async def list_pr_comments(self, repo_path: Path, pr_number: int) -> list[dict]:
199
+ """List comments on a PR."""
200
+ await self.ensure_authenticated()
201
+
202
+ cmd = [
203
+ self.gh_path,
204
+ "pr",
205
+ "view",
206
+ str(pr_number),
207
+ "--json",
208
+ "comments",
209
+ "--jq",
210
+ ".comments",
211
+ ]
212
+
213
+ try:
214
+ result = subprocess.run(
215
+ cmd, cwd=repo_path, capture_output=True, text=True, timeout=10
216
+ )
217
+ if result.returncode != 0:
218
+ raise RuntimeError(
219
+ f"Failed to list PR comments: {result.stderr.strip()}"
220
+ )
221
+ return json.loads(result.stdout) if result.stdout.strip() else []
222
+ except Exception as e:
223
+ raise RuntimeError(f"Failed to list PR comments: {e}")
224
+
225
+ async def merge_pr(
226
+ self,
227
+ repo_path: Path,
228
+ pr_number: int,
229
+ strategy: str = "squash",
230
+ ) -> dict:
231
+ """Merge a PR with the given strategy.
232
+
233
+ Args:
234
+ repo_path: Repo path for gh CLI context
235
+ pr_number: PR number to merge
236
+ strategy: One of 'squash', 'merge', 'rebase'
237
+ """
238
+ await self.ensure_authenticated()
239
+
240
+ strategy_flag = {
241
+ "squash": "--squash",
242
+ "merge": "--merge",
243
+ "rebase": "--rebase",
244
+ }.get(strategy, "--squash")
245
+
246
+ cmd = [
247
+ self.gh_path,
248
+ "pr",
249
+ "merge",
250
+ str(pr_number),
251
+ strategy_flag,
252
+ "--delete-branch",
253
+ ]
254
+
255
+ try:
256
+ result = subprocess.run(
257
+ cmd, cwd=repo_path, capture_output=True, text=True, timeout=30
258
+ )
259
+ if result.returncode != 0:
260
+ raise RuntimeError(f"Failed to merge PR: {result.stderr.strip()}")
261
+ return {
262
+ "success": True,
263
+ "message": f"PR #{pr_number} merged via {strategy}",
264
+ }
265
+ except subprocess.TimeoutExpired:
266
+ raise RuntimeError("PR merge timed out after 30 seconds")
267
+ except RuntimeError:
268
+ raise
269
+ except Exception as e:
270
+ raise RuntimeError(f"Failed to merge PR: {e}")
@@ -0,0 +1,194 @@
1
+ """GitLab provider implementation using glab CLI."""
2
+
3
+ import json
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ from app.exceptions import ConfigurationError
10
+ from app.services.git_host.protocol import PullRequest
11
+
12
+
13
+ class GitLabProvider:
14
+ """GitLab provider using the glab CLI."""
15
+
16
+ def __init__(self) -> None:
17
+ self._glab_path: str | None = None
18
+ self._authenticated: bool | None = None
19
+
20
+ @property
21
+ def name(self) -> str:
22
+ return "gitlab"
23
+
24
+ @property
25
+ def glab_path(self) -> str:
26
+ if self._glab_path is None:
27
+ self._glab_path = shutil.which("glab")
28
+ if not self._glab_path:
29
+ raise ConfigurationError(
30
+ "GitLab CLI (glab) not found. "
31
+ "Install from https://gitlab.com/gitlab-org/cli"
32
+ )
33
+ return self._glab_path
34
+
35
+ def is_available(self) -> bool:
36
+ try:
37
+ return bool(shutil.which("glab"))
38
+ except Exception:
39
+ return False
40
+
41
+ async def is_authenticated(self) -> bool:
42
+ if self._authenticated is not None:
43
+ return self._authenticated
44
+ try:
45
+ result = subprocess.run(
46
+ [self.glab_path, "auth", "status"],
47
+ capture_output=True,
48
+ text=True,
49
+ timeout=5,
50
+ )
51
+ self._authenticated = result.returncode == 0
52
+ return self._authenticated
53
+ except Exception:
54
+ self._authenticated = False
55
+ return False
56
+
57
+ async def ensure_authenticated(self) -> None:
58
+ if not await self.is_authenticated():
59
+ raise ConfigurationError(
60
+ "Not authenticated with GitLab. Run 'glab auth login' first."
61
+ )
62
+
63
+ async def create_pr(
64
+ self,
65
+ repo_path: Path,
66
+ title: str,
67
+ body: str,
68
+ head_branch: str,
69
+ base_branch: str,
70
+ ) -> PullRequest:
71
+ await self.ensure_authenticated()
72
+
73
+ cmd = [
74
+ self.glab_path,
75
+ "mr",
76
+ "create",
77
+ "--title",
78
+ title,
79
+ "--description",
80
+ body,
81
+ "--target-branch",
82
+ base_branch,
83
+ "--source-branch",
84
+ head_branch,
85
+ "--no-editor",
86
+ ]
87
+
88
+ try:
89
+ result = subprocess.run(
90
+ cmd, cwd=repo_path, capture_output=True, text=True, timeout=30
91
+ )
92
+
93
+ if result.returncode != 0:
94
+ raise RuntimeError(f"Failed to create MR: {result.stderr.strip()}")
95
+
96
+ mr_url = result.stdout.strip()
97
+ # Extract MR number from URL like https://gitlab.com/org/repo/-/merge_requests/123
98
+ mr_number_match = re.search(r"/merge_requests/(\d+)", mr_url)
99
+ if not mr_number_match:
100
+ # Try to find it in any line
101
+ for line in mr_url.split("\n"):
102
+ mr_number_match = re.search(r"/merge_requests/(\d+)", line)
103
+ if mr_number_match:
104
+ mr_url = line.strip()
105
+ break
106
+
107
+ if not mr_number_match:
108
+ raise RuntimeError(f"Could not extract MR number from output: {mr_url}")
109
+
110
+ return PullRequest(
111
+ number=int(mr_number_match.group(1)),
112
+ url=mr_url,
113
+ title=title,
114
+ state="OPEN",
115
+ head_branch=head_branch,
116
+ base_branch=base_branch,
117
+ merged=False,
118
+ )
119
+
120
+ except subprocess.TimeoutExpired:
121
+ raise RuntimeError("MR creation timed out after 30 seconds")
122
+ except RuntimeError:
123
+ raise
124
+ except Exception as e:
125
+ raise RuntimeError(f"Failed to create MR: {e}")
126
+
127
+ async def get_pr_status(self, repo_path: Path, pr_number: int) -> str:
128
+ await self.ensure_authenticated()
129
+
130
+ cmd = [
131
+ self.glab_path,
132
+ "mr",
133
+ "view",
134
+ str(pr_number),
135
+ "--output",
136
+ "json",
137
+ ]
138
+
139
+ try:
140
+ result = subprocess.run(
141
+ cmd, cwd=repo_path, capture_output=True, text=True, timeout=10
142
+ )
143
+ if result.returncode != 0:
144
+ raise RuntimeError(f"Failed to get MR status: {result.stderr}")
145
+
146
+ data = json.loads(result.stdout)
147
+ state = data.get("state", "").upper()
148
+ if state == "MERGED":
149
+ return "MERGED"
150
+ elif state == "CLOSED":
151
+ return "CLOSED"
152
+ return "OPEN"
153
+ except Exception as e:
154
+ raise RuntimeError(f"Failed to get MR status: {e}")
155
+
156
+ async def get_pr_details(self, repo_path: Path, pr_number: int) -> dict[str, any]:
157
+ await self.ensure_authenticated()
158
+
159
+ cmd = [
160
+ self.glab_path,
161
+ "mr",
162
+ "view",
163
+ str(pr_number),
164
+ "--output",
165
+ "json",
166
+ ]
167
+
168
+ try:
169
+ result = subprocess.run(
170
+ cmd, cwd=repo_path, capture_output=True, text=True, timeout=10
171
+ )
172
+ if result.returncode != 0:
173
+ raise RuntimeError(f"Failed to get MR details: {result.stderr}")
174
+
175
+ data = json.loads(result.stdout)
176
+ # Normalize to the same shape as GitHub
177
+ state = data.get("state", "").upper()
178
+ merged = state == "MERGED"
179
+ if not merged and state == "CLOSED":
180
+ pass # keep as CLOSED
181
+ elif not merged:
182
+ state = "OPEN"
183
+
184
+ return {
185
+ "number": data.get("iid", pr_number),
186
+ "title": data.get("title", ""),
187
+ "state": state,
188
+ "url": data.get("web_url", ""),
189
+ "headRefName": data.get("source_branch", ""),
190
+ "baseRefName": data.get("target_branch", ""),
191
+ "merged": merged,
192
+ }
193
+ except Exception as e:
194
+ raise RuntimeError(f"Failed to get MR details: {e}")
@@ -0,0 +1,75 @@
1
+ """Git host provider protocol definition."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Protocol, runtime_checkable
8
+
9
+
10
+ @dataclass
11
+ class PullRequest:
12
+ """Represents a pull/merge request."""
13
+
14
+ number: int
15
+ url: str
16
+ title: str
17
+ state: str # 'OPEN', 'CLOSED', 'MERGED'
18
+ head_branch: str
19
+ base_branch: str
20
+ merged: bool = False
21
+
22
+
23
+ @runtime_checkable
24
+ class GitHostProvider(Protocol):
25
+ """Protocol for git hosting providers (GitHub, GitLab, etc.)."""
26
+
27
+ @property
28
+ def name(self) -> str:
29
+ """Provider name (e.g. 'github', 'gitlab')."""
30
+ ...
31
+
32
+ def is_available(self) -> bool:
33
+ """Check if the CLI tool for this provider is installed."""
34
+ ...
35
+
36
+ async def is_authenticated(self) -> bool:
37
+ """Check if the user is authenticated with this provider."""
38
+ ...
39
+
40
+ async def ensure_authenticated(self) -> None:
41
+ """Ensure user is authenticated, raise ConfigurationError if not."""
42
+ ...
43
+
44
+ async def create_pr(
45
+ self,
46
+ repo_path: Path,
47
+ title: str,
48
+ body: str,
49
+ head_branch: str,
50
+ base_branch: str,
51
+ ) -> PullRequest:
52
+ """Create a pull/merge request."""
53
+ ...
54
+
55
+ async def get_pr_status(self, repo_path: Path, pr_number: int) -> str:
56
+ """Get the status of a PR: 'OPEN', 'CLOSED', or 'MERGED'."""
57
+ ...
58
+
59
+ async def get_pr_details(self, repo_path: Path, pr_number: int) -> dict[str, any]:
60
+ """Get detailed information about a PR."""
61
+ ...
62
+
63
+ async def add_pr_comment(self, repo_path: Path, pr_number: int, body: str) -> dict:
64
+ """Add a comment to a PR."""
65
+ ...
66
+
67
+ async def list_pr_comments(self, repo_path: Path, pr_number: int) -> list[dict]:
68
+ """List comments on a PR."""
69
+ ...
70
+
71
+ async def merge_pr(
72
+ self, repo_path: Path, pr_number: int, strategy: str = "squash"
73
+ ) -> dict:
74
+ """Merge a PR with the given strategy (squash, merge, rebase)."""
75
+ ...