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,384 @@
1
+ """Git operations for conflict detection and rebase support.
2
+
3
+ Provides conflict detection, rebase, continue/abort operations
4
+ that work with worktree-based branches.
5
+ """
6
+
7
+ import logging
8
+ import subprocess
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @dataclass
16
+ class ConflictState:
17
+ """Current conflict state of a worktree or repo."""
18
+
19
+ operation: str # "rebase", "merge", "cherry_pick", "revert"
20
+ conflicted_files: list[str] = field(default_factory=list)
21
+ can_continue: bool = True
22
+ can_abort: bool = True
23
+
24
+
25
+ @dataclass
26
+ class RebaseResult:
27
+ """Result of a rebase operation."""
28
+
29
+ success: bool
30
+ message: str
31
+ has_conflicts: bool = False
32
+ conflicted_files: list[str] = field(default_factory=list)
33
+
34
+
35
+ def _run_git(
36
+ args: list[str], cwd: Path, timeout: int = 30
37
+ ) -> subprocess.CompletedProcess:
38
+ """Run a git command and return the result."""
39
+ return subprocess.run(
40
+ ["git"] + args,
41
+ cwd=cwd,
42
+ capture_output=True,
43
+ text=True,
44
+ timeout=timeout,
45
+ )
46
+
47
+
48
+ def detect_conflict_state(worktree_path: Path) -> ConflictState | None:
49
+ """Detect if a worktree (or repo) is in a conflicted state.
50
+
51
+ Checks for rebase-merge, rebase-apply, MERGE_HEAD, CHERRY_PICK_HEAD.
52
+
53
+ Returns ConflictState if in conflict, None otherwise.
54
+ """
55
+ # Find the actual .git dir (worktrees use a .git file pointing to main repo)
56
+ git_path = worktree_path / ".git"
57
+ if git_path.is_file():
58
+ # Worktree: .git is a file with "gitdir: <path>"
59
+ gitdir_content = git_path.read_text().strip()
60
+ if gitdir_content.startswith("gitdir: "):
61
+ actual_git_dir = Path(gitdir_content[8:])
62
+ if not actual_git_dir.is_absolute():
63
+ actual_git_dir = (worktree_path / actual_git_dir).resolve()
64
+ else:
65
+ actual_git_dir = git_path
66
+ elif git_path.is_dir():
67
+ actual_git_dir = git_path
68
+ else:
69
+ return None
70
+
71
+ operation = None
72
+
73
+ # Check for rebase in progress
74
+ if (actual_git_dir / "rebase-merge").is_dir():
75
+ operation = "rebase"
76
+ elif (actual_git_dir / "rebase-apply").is_dir():
77
+ operation = "rebase"
78
+ elif (actual_git_dir / "MERGE_HEAD").exists():
79
+ operation = "merge"
80
+ elif (actual_git_dir / "CHERRY_PICK_HEAD").exists():
81
+ operation = "cherry_pick"
82
+ elif (actual_git_dir / "REVERT_HEAD").exists():
83
+ operation = "revert"
84
+
85
+ if operation is None:
86
+ # Also check for unmerged files (possible leftover conflict)
87
+ conflicted = get_conflicted_files(worktree_path)
88
+ if conflicted:
89
+ return ConflictState(
90
+ operation="unknown",
91
+ conflicted_files=conflicted,
92
+ can_continue=False,
93
+ can_abort=False,
94
+ )
95
+ return None
96
+
97
+ conflicted = get_conflicted_files(worktree_path)
98
+ return ConflictState(
99
+ operation=operation,
100
+ conflicted_files=conflicted,
101
+ can_continue=len(conflicted) == 0,
102
+ can_abort=True,
103
+ )
104
+
105
+
106
+ def get_conflicted_files(worktree_path: Path) -> list[str]:
107
+ """Get list of files with unresolved conflicts."""
108
+ try:
109
+ result = _run_git(
110
+ ["diff", "--name-only", "--diff-filter=U"],
111
+ cwd=worktree_path,
112
+ timeout=10,
113
+ )
114
+ if result.returncode == 0 and result.stdout.strip():
115
+ return [f.strip() for f in result.stdout.strip().split("\n") if f.strip()]
116
+ except (subprocess.TimeoutExpired, OSError):
117
+ pass
118
+ return []
119
+
120
+
121
+ def rebase_branch(
122
+ worktree_path: Path,
123
+ onto_branch: str = "main",
124
+ ) -> RebaseResult:
125
+ """Rebase the current worktree branch onto another branch.
126
+
127
+ Args:
128
+ worktree_path: Path to the worktree
129
+ onto_branch: Branch to rebase onto (default: main)
130
+
131
+ Returns:
132
+ RebaseResult with success/conflict info
133
+ """
134
+ logger.info(f"Rebasing worktree {worktree_path} onto {onto_branch}")
135
+
136
+ # Fetch latest
137
+ _run_git(["fetch", "origin"], cwd=worktree_path, timeout=30)
138
+
139
+ result = _run_git(
140
+ ["rebase", onto_branch],
141
+ cwd=worktree_path,
142
+ timeout=60,
143
+ )
144
+
145
+ if result.returncode == 0:
146
+ return RebaseResult(
147
+ success=True,
148
+ message=f"Successfully rebased onto {onto_branch}",
149
+ )
150
+
151
+ # Check if it's a conflict
152
+ conflicted = get_conflicted_files(worktree_path)
153
+ if conflicted:
154
+ return RebaseResult(
155
+ success=False,
156
+ message=f"Rebase conflicts in {len(conflicted)} file(s). Resolve conflicts and continue, or abort.",
157
+ has_conflicts=True,
158
+ conflicted_files=conflicted,
159
+ )
160
+
161
+ return RebaseResult(
162
+ success=False,
163
+ message=f"Rebase failed: {result.stderr.strip() or result.stdout.strip()}",
164
+ )
165
+
166
+
167
+ def continue_rebase(worktree_path: Path) -> RebaseResult:
168
+ """Continue a paused rebase after conflicts are resolved."""
169
+ logger.info(f"Continuing rebase in {worktree_path}")
170
+
171
+ # Stage all resolved files
172
+ _run_git(["add", "--all"], cwd=worktree_path, timeout=10)
173
+
174
+ result = _run_git(
175
+ ["rebase", "--continue"],
176
+ cwd=worktree_path,
177
+ timeout=60,
178
+ )
179
+
180
+ if result.returncode == 0:
181
+ return RebaseResult(
182
+ success=True,
183
+ message="Rebase completed successfully",
184
+ )
185
+
186
+ conflicted = get_conflicted_files(worktree_path)
187
+ if conflicted:
188
+ return RebaseResult(
189
+ success=False,
190
+ message=f"More conflicts found in {len(conflicted)} file(s)",
191
+ has_conflicts=True,
192
+ conflicted_files=conflicted,
193
+ )
194
+
195
+ return RebaseResult(
196
+ success=False,
197
+ message=f"Continue rebase failed: {result.stderr.strip()}",
198
+ )
199
+
200
+
201
+ def abort_operation(worktree_path: Path) -> bool:
202
+ """Abort the current conflict operation (rebase/merge/cherry-pick).
203
+
204
+ Detects the current operation and runs the appropriate abort command.
205
+ """
206
+ state = detect_conflict_state(worktree_path)
207
+ if not state:
208
+ return True # Nothing to abort
209
+
210
+ logger.info(f"Aborting {state.operation} in {worktree_path}")
211
+
212
+ abort_commands = {
213
+ "rebase": ["rebase", "--abort"],
214
+ "merge": ["merge", "--abort"],
215
+ "cherry_pick": ["cherry-pick", "--abort"],
216
+ "revert": ["revert", "--abort"],
217
+ }
218
+
219
+ cmd = abort_commands.get(state.operation)
220
+ if not cmd:
221
+ logger.warning(f"Unknown operation to abort: {state.operation}")
222
+ return False
223
+
224
+ result = _run_git(cmd, cwd=worktree_path, timeout=30)
225
+ if result.returncode != 0:
226
+ logger.error(f"Abort failed: {result.stderr}")
227
+ return False
228
+
229
+ return True
230
+
231
+
232
+ @dataclass
233
+ class PushResult:
234
+ """Result of a push operation."""
235
+
236
+ success: bool
237
+ message: str
238
+
239
+
240
+ def push_branch(
241
+ repo_path: Path,
242
+ branch: str,
243
+ remote: str = "origin",
244
+ ) -> PushResult:
245
+ """Push a branch to the remote.
246
+
247
+ Args:
248
+ repo_path: Path to the repo or worktree
249
+ branch: Branch name to push
250
+ remote: Remote name (default: origin)
251
+
252
+ Returns:
253
+ PushResult with success/error info
254
+ """
255
+ logger.info(f"Pushing {branch} to {remote}")
256
+
257
+ result = _run_git(
258
+ ["push", "-u", remote, branch],
259
+ cwd=repo_path,
260
+ timeout=60,
261
+ )
262
+
263
+ if result.returncode == 0:
264
+ return PushResult(
265
+ success=True,
266
+ message=f"Successfully pushed {branch} to {remote}",
267
+ )
268
+
269
+ return PushResult(
270
+ success=False,
271
+ message=f"Push failed: {result.stderr.strip() or result.stdout.strip()}",
272
+ )
273
+
274
+
275
+ def force_push_branch(
276
+ repo_path: Path,
277
+ branch: str,
278
+ remote: str = "origin",
279
+ ) -> PushResult:
280
+ """Force-push a branch using --force-with-lease for safety.
281
+
282
+ Args:
283
+ repo_path: Path to the repo or worktree
284
+ branch: Branch name to push
285
+ remote: Remote name (default: origin)
286
+
287
+ Returns:
288
+ PushResult with success/error info
289
+ """
290
+ logger.info(f"Force-pushing {branch} to {remote} (--force-with-lease)")
291
+
292
+ result = _run_git(
293
+ ["push", "--force-with-lease", remote, branch],
294
+ cwd=repo_path,
295
+ timeout=60,
296
+ )
297
+
298
+ if result.returncode == 0:
299
+ return PushResult(
300
+ success=True,
301
+ message=f"Successfully force-pushed {branch} to {remote}",
302
+ )
303
+
304
+ return PushResult(
305
+ success=False,
306
+ message=f"Force-push failed: {result.stderr.strip() or result.stdout.strip()}",
307
+ )
308
+
309
+
310
+ def get_push_status(repo_path: Path, branch: str, remote: str = "origin") -> dict:
311
+ """Check if local branch is ahead/behind the remote tracking branch.
312
+
313
+ Returns dict with ahead/behind counts relative to remote.
314
+ """
315
+ # Fetch latest remote state
316
+ _run_git(["fetch", remote], cwd=repo_path, timeout=30)
317
+
318
+ remote_branch = f"{remote}/{branch}"
319
+
320
+ # Check if remote branch exists
321
+ check = _run_git(
322
+ ["rev-parse", "--verify", remote_branch],
323
+ cwd=repo_path,
324
+ timeout=10,
325
+ )
326
+ if check.returncode != 0:
327
+ return {
328
+ "ahead": 0,
329
+ "behind": 0,
330
+ "remote_exists": False,
331
+ "needs_push": True,
332
+ }
333
+
334
+ behind_result = _run_git(
335
+ ["rev-list", "--count", f"{branch}..{remote_branch}"],
336
+ cwd=repo_path,
337
+ timeout=10,
338
+ )
339
+ behind = int(behind_result.stdout.strip()) if behind_result.returncode == 0 else 0
340
+
341
+ ahead_result = _run_git(
342
+ ["rev-list", "--count", f"{remote_branch}..{branch}"],
343
+ cwd=repo_path,
344
+ timeout=10,
345
+ )
346
+ ahead = int(ahead_result.stdout.strip()) if ahead_result.returncode == 0 else 0
347
+
348
+ return {
349
+ "ahead": ahead,
350
+ "behind": behind,
351
+ "remote_exists": True,
352
+ "needs_push": ahead > 0,
353
+ }
354
+
355
+
356
+ def get_divergence_info(
357
+ repo_path: Path, branch_name: str, target_branch: str = "main"
358
+ ) -> dict:
359
+ """Get divergence info between two branches.
360
+
361
+ Returns dict with ahead/behind counts.
362
+ """
363
+ # Commits in target not in branch (branch is behind)
364
+ behind_result = _run_git(
365
+ ["rev-list", "--count", f"{branch_name}..{target_branch}"],
366
+ cwd=repo_path,
367
+ timeout=10,
368
+ )
369
+ behind = int(behind_result.stdout.strip()) if behind_result.returncode == 0 else 0
370
+
371
+ # Commits in branch not in target (branch is ahead)
372
+ ahead_result = _run_git(
373
+ ["rev-list", "--count", f"{target_branch}..{branch_name}"],
374
+ cwd=repo_path,
375
+ timeout=10,
376
+ )
377
+ ahead = int(ahead_result.stdout.strip()) if ahead_result.returncode == 0 else 0
378
+
379
+ return {
380
+ "ahead": ahead,
381
+ "behind": behind,
382
+ "diverged": behind > 0 and ahead > 0,
383
+ "up_to_date": behind == 0,
384
+ }
@@ -0,0 +1,233 @@
1
+ """Service for GitHub integration via GitHub CLI."""
2
+
3
+ import json
4
+ import shutil
5
+ import subprocess
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from app.exceptions import ConfigurationError
10
+
11
+
12
+ @dataclass
13
+ class PullRequest:
14
+ """Represents a GitHub pull request."""
15
+
16
+ number: int
17
+ url: str
18
+ title: str
19
+ state: str # 'OPEN', 'CLOSED', 'MERGED'
20
+ head_branch: str
21
+ base_branch: str
22
+ merged: bool = False
23
+
24
+
25
+ class GitHubService:
26
+ """Service for interacting with GitHub via CLI."""
27
+
28
+ def __init__(self):
29
+ self._gh_path: str | None = None
30
+ self._authenticated: bool | None = None
31
+
32
+ @property
33
+ def gh_path(self) -> str:
34
+ """Get path to gh CLI executable."""
35
+ if self._gh_path is None:
36
+ self._gh_path = shutil.which("gh")
37
+ if not self._gh_path:
38
+ raise ConfigurationError(
39
+ "GitHub CLI (gh) not found. Install from https://cli.github.com/"
40
+ )
41
+ return self._gh_path
42
+
43
+ def is_available(self) -> bool:
44
+ """Check if GitHub CLI is available."""
45
+ try:
46
+ return bool(shutil.which("gh"))
47
+ except Exception:
48
+ return False
49
+
50
+ async def is_authenticated(self) -> bool:
51
+ """Check if user is authenticated with GitHub CLI."""
52
+ if self._authenticated is not None:
53
+ return self._authenticated
54
+
55
+ try:
56
+ result = subprocess.run(
57
+ [self.gh_path, "auth", "status"],
58
+ capture_output=True,
59
+ text=True,
60
+ timeout=5,
61
+ )
62
+ # gh auth status returns 0 if authenticated
63
+ self._authenticated = result.returncode == 0
64
+ return self._authenticated
65
+ except Exception:
66
+ self._authenticated = False
67
+ return False
68
+
69
+ async def ensure_authenticated(self):
70
+ """Ensure user is authenticated, raise if not."""
71
+ if not await self.is_authenticated():
72
+ raise ConfigurationError(
73
+ "Not authenticated with GitHub. Run 'gh auth login' first."
74
+ )
75
+
76
+ async def create_pr(
77
+ self,
78
+ repo_path: Path,
79
+ title: str,
80
+ body: str,
81
+ head_branch: str,
82
+ base_branch: str,
83
+ ) -> PullRequest:
84
+ """
85
+ Create a pull request.
86
+
87
+ Args:
88
+ repo_path: Path to the git repository
89
+ title: PR title
90
+ body: PR description
91
+ head_branch: Source branch (feature branch)
92
+ base_branch: Target branch (e.g., 'main')
93
+
94
+ Returns:
95
+ PullRequest object with PR details
96
+
97
+ Raises:
98
+ ConfigurationError: If gh CLI not available or not authenticated
99
+ RuntimeError: If PR creation fails
100
+ """
101
+ await self.ensure_authenticated()
102
+
103
+ # Build gh pr create command
104
+ cmd = [
105
+ self.gh_path,
106
+ "pr",
107
+ "create",
108
+ "--title",
109
+ title,
110
+ "--body",
111
+ body,
112
+ "--base",
113
+ base_branch,
114
+ "--head",
115
+ head_branch,
116
+ ]
117
+
118
+ try:
119
+ result = subprocess.run(
120
+ cmd, cwd=repo_path, capture_output=True, text=True, timeout=30
121
+ )
122
+
123
+ if result.returncode != 0:
124
+ error_msg = result.stderr.strip()
125
+ raise RuntimeError(f"Failed to create PR: {error_msg}")
126
+
127
+ # Parse PR URL from output
128
+ pr_url = result.stdout.strip()
129
+
130
+ # Extract PR number from URL
131
+ # Example: https://github.com/owner/repo/pull/123
132
+ import re
133
+
134
+ pr_number_match = re.search(r"/pull/(\d+)", pr_url)
135
+ if not pr_number_match:
136
+ raise RuntimeError(f"Could not extract PR number from URL: {pr_url}")
137
+
138
+ pr_number = int(pr_number_match.group(1))
139
+
140
+ return PullRequest(
141
+ number=pr_number,
142
+ url=pr_url,
143
+ title=title,
144
+ state="OPEN",
145
+ head_branch=head_branch,
146
+ base_branch=base_branch,
147
+ merged=False,
148
+ )
149
+
150
+ except subprocess.TimeoutExpired:
151
+ raise RuntimeError("PR creation timed out after 30 seconds")
152
+ except Exception as e:
153
+ raise RuntimeError(f"Failed to create PR: {e}")
154
+
155
+ async def get_pr_status(self, repo_path: Path, pr_number: int) -> str:
156
+ """
157
+ Get the status of a pull request.
158
+
159
+ Args:
160
+ repo_path: Path to the git repository
161
+ pr_number: PR number
162
+
163
+ Returns:
164
+ Status string: 'OPEN', 'CLOSED', 'MERGED'
165
+ """
166
+ await self.ensure_authenticated()
167
+
168
+ cmd = [
169
+ self.gh_path,
170
+ "pr",
171
+ "view",
172
+ str(pr_number),
173
+ "--json",
174
+ "state",
175
+ "--jq",
176
+ ".state",
177
+ ]
178
+
179
+ try:
180
+ result = subprocess.run(
181
+ cmd, cwd=repo_path, capture_output=True, text=True, timeout=10
182
+ )
183
+
184
+ if result.returncode != 0:
185
+ raise RuntimeError(f"Failed to get PR status: {result.stderr}")
186
+
187
+ return result.stdout.strip()
188
+
189
+ except Exception as e:
190
+ raise RuntimeError(f"Failed to get PR status: {e}")
191
+
192
+ async def get_pr_details(self, repo_path: Path, pr_number: int) -> dict[str, any]:
193
+ """
194
+ Get detailed information about a PR.
195
+
196
+ Returns:
197
+ Dict with keys: number, title, state, url, headRefName, baseRefName, merged
198
+ """
199
+ await self.ensure_authenticated()
200
+
201
+ cmd = [
202
+ self.gh_path,
203
+ "pr",
204
+ "view",
205
+ str(pr_number),
206
+ "--json",
207
+ "number,title,state,url,headRefName,baseRefName,merged",
208
+ ]
209
+
210
+ try:
211
+ result = subprocess.run(
212
+ cmd, cwd=repo_path, capture_output=True, text=True, timeout=10
213
+ )
214
+
215
+ if result.returncode != 0:
216
+ raise RuntimeError(f"Failed to get PR details: {result.stderr}")
217
+
218
+ return json.loads(result.stdout)
219
+
220
+ except Exception as e:
221
+ raise RuntimeError(f"Failed to get PR details: {e}")
222
+
223
+
224
+ # Singleton instance
225
+ _github_service: GitHubService | None = None
226
+
227
+
228
+ def get_github_service() -> GitHubService:
229
+ """Get or create GitHubService singleton."""
230
+ global _github_service
231
+ if _github_service is None:
232
+ _github_service = GitHubService()
233
+ return _github_service