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,784 @@
1
+ """Service for merging worktree branches into the default branch."""
2
+
3
+ import json
4
+ import logging
5
+ import subprocess
6
+ import time
7
+ import uuid
8
+ from dataclasses import dataclass, field
9
+ from enum import StrEnum
10
+ from pathlib import Path
11
+
12
+ from sqlalchemy import select
13
+ from sqlalchemy.ext.asyncio import AsyncSession
14
+ from sqlalchemy.orm import selectinload
15
+
16
+ from app.data_dir import get_evidence_dir, get_worktrees_root
17
+ from app.exceptions import ResourceNotFoundError, ValidationError
18
+ from app.models.enums import ActorType, EventType
19
+ from app.models.evidence import Evidence, EvidenceKind
20
+ from app.models.revision import RevisionStatus
21
+ from app.models.ticket import Ticket
22
+ from app.models.ticket_event import TicketEvent
23
+ from app.models.workspace import Workspace
24
+ from app.services.config_service import DraftConfig
25
+ from app.services.workspace_service import WorkspaceService
26
+ from app.state_machine import TicketState
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class MergeStrategy(StrEnum):
32
+ """Supported merge strategies."""
33
+
34
+ MERGE = "merge"
35
+ REBASE = "rebase"
36
+
37
+
38
+ @dataclass
39
+ class MergeResult:
40
+ """Result of a merge operation."""
41
+
42
+ success: bool
43
+ message: str
44
+ exit_code: int
45
+ stdout: str
46
+ stderr: str
47
+ default_branch: str | None = None
48
+ evidence_ids: dict[str, str] = field(
49
+ default_factory=dict
50
+ ) # stdout_id, stderr_id, meta_id
51
+ # Warning if merge succeeded but pull was skipped/failed (local-only merge)
52
+ pull_warning: str | None = None
53
+
54
+
55
+ class MergeService:
56
+ """Service for merging worktree branches into the default branch.
57
+
58
+ This service handles the git operations required to merge changes
59
+ from an isolated worktree back into the main repository's default branch.
60
+
61
+ Safety:
62
+ - Only operates on worktrees under .draft/worktrees/
63
+ - Never modifies protected branches directly
64
+ - Validates ticket is in 'done' state with approved revision
65
+ - Captures all git output as evidence (stdout AND stderr)
66
+ - Validates worktree is clean before merge
67
+ - Fetches remote before merge to detect divergence
68
+ """
69
+
70
+ PROTECTED_BRANCHES = {"main", "master", "develop", "production", "staging"}
71
+
72
+ def __init__(self, db: AsyncSession, board_config: dict | None = None):
73
+ self.db = db
74
+ self._config = DraftConfig.from_board_config(board_config)
75
+
76
+ async def merge_ticket(
77
+ self,
78
+ ticket_id: str,
79
+ strategy: MergeStrategy = MergeStrategy.MERGE,
80
+ delete_worktree: bool = True,
81
+ cleanup_artifacts: bool = True,
82
+ actor_id: str = "merge_service",
83
+ ) -> MergeResult:
84
+ """Merge a ticket's worktree branch into the default branch.
85
+
86
+ Args:
87
+ ticket_id: The UUID of the ticket
88
+ strategy: Merge strategy (merge or rebase)
89
+ delete_worktree: Whether to delete the worktree after merge
90
+ cleanup_artifacts: Whether to cleanup evidence files
91
+ actor_id: ID of the actor performing the merge
92
+
93
+ Returns:
94
+ MergeResult with success status and details
95
+
96
+ Raises:
97
+ ResourceNotFoundError: If ticket or workspace not found
98
+ ValidationError: If ticket is not in valid state for merge
99
+ ConflictError: If merge cannot proceed due to conflicts
100
+ """
101
+ # Fetch ticket with workspace and revisions
102
+ result = await self.db.execute(
103
+ select(Ticket)
104
+ .where(Ticket.id == ticket_id)
105
+ .options(
106
+ selectinload(Ticket.workspace),
107
+ selectinload(Ticket.revisions),
108
+ )
109
+ )
110
+ ticket = result.scalar_one_or_none()
111
+ if ticket is None:
112
+ raise ResourceNotFoundError("Ticket", ticket_id)
113
+
114
+ # Validate ticket state
115
+ if ticket.state != TicketState.DONE.value:
116
+ raise ValidationError(
117
+ f"Ticket must be in 'done' state to merge. Current state: {ticket.state}"
118
+ )
119
+
120
+ # Validate approved revision exists
121
+ approved_revision = next(
122
+ (r for r in ticket.revisions if r.status == RevisionStatus.APPROVED.value),
123
+ None,
124
+ )
125
+ if approved_revision is None:
126
+ raise ValidationError("Ticket must have an approved revision to merge")
127
+
128
+ # Validate workspace exists
129
+ workspace = ticket.workspace
130
+ if workspace is None or not workspace.is_active:
131
+ raise ValidationError("Ticket has no active workspace to merge from")
132
+
133
+ worktree_path = Path(workspace.worktree_path)
134
+ if not worktree_path.exists():
135
+ raise ValidationError(f"Worktree path does not exist: {worktree_path}")
136
+
137
+ # Validate worktree is under central data dir or legacy .draft/worktrees/
138
+ repo_path = WorkspaceService.get_repo_path()
139
+ central_worktrees = get_worktrees_root()
140
+ legacy_worktrees = repo_path / ".draft" / "worktrees"
141
+ resolved = worktree_path.resolve()
142
+ under_central = False
143
+ under_legacy = False
144
+ try:
145
+ resolved.relative_to(central_worktrees.resolve())
146
+ under_central = True
147
+ except ValueError:
148
+ pass
149
+ try:
150
+ resolved.relative_to(legacy_worktrees.resolve())
151
+ under_legacy = True
152
+ except ValueError:
153
+ pass
154
+ if not under_central and not under_legacy:
155
+ raise ValidationError(
156
+ f"Worktree must be under a known worktrees directory: {worktree_path}"
157
+ )
158
+
159
+ # Detect default branch early for event payload
160
+ default_branch = self._detect_default_branch(repo_path)
161
+
162
+ # Record merge requested event
163
+ await self._create_event(
164
+ ticket_id=ticket_id,
165
+ event_type=EventType.MERGE_REQUESTED,
166
+ reason=f"Merge requested with strategy '{strategy.value}'",
167
+ payload={
168
+ "strategy": strategy.value,
169
+ "worktree_branch": workspace.branch_name,
170
+ "base_branch": default_branch,
171
+ "worktree_path": str(worktree_path),
172
+ },
173
+ actor_id=actor_id,
174
+ )
175
+ await self.db.commit()
176
+
177
+ # Perform the merge
178
+ merge_result = await self._perform_merge(
179
+ ticket_id=ticket_id,
180
+ workspace=workspace,
181
+ strategy=strategy,
182
+ default_branch=default_branch,
183
+ )
184
+
185
+ if merge_result.success:
186
+ # Record success event with full details
187
+ payload = {
188
+ "strategy": strategy.value,
189
+ "worktree_branch": workspace.branch_name,
190
+ "base_branch": default_branch,
191
+ "exit_code": merge_result.exit_code,
192
+ "evidence_ids": merge_result.evidence_ids,
193
+ }
194
+ # Include warning if merge happened without pulling latest
195
+ if merge_result.pull_warning:
196
+ payload["pull_warning"] = merge_result.pull_warning
197
+
198
+ await self._create_event(
199
+ ticket_id=ticket_id,
200
+ event_type=EventType.MERGE_SUCCEEDED,
201
+ reason=f"Merge succeeded: {merge_result.message}",
202
+ payload=payload,
203
+ actor_id=actor_id,
204
+ )
205
+ await self.db.commit()
206
+
207
+ # Cleanup if requested
208
+ if delete_worktree:
209
+ await self._cleanup_worktree(
210
+ ticket_id=ticket_id,
211
+ workspace=workspace,
212
+ actor_id=actor_id,
213
+ )
214
+ else:
215
+ # Record failure event with full details
216
+ await self._create_event(
217
+ ticket_id=ticket_id,
218
+ event_type=EventType.MERGE_FAILED,
219
+ reason=f"Merge failed: {merge_result.message}",
220
+ payload={
221
+ "strategy": strategy.value,
222
+ "worktree_branch": workspace.branch_name,
223
+ "base_branch": default_branch,
224
+ "exit_code": merge_result.exit_code,
225
+ "evidence_ids": merge_result.evidence_ids,
226
+ },
227
+ actor_id=actor_id,
228
+ )
229
+ await self.db.commit()
230
+
231
+ return merge_result
232
+
233
+ async def _perform_merge(
234
+ self,
235
+ ticket_id: str,
236
+ workspace: Workspace,
237
+ strategy: MergeStrategy,
238
+ default_branch: str,
239
+ ) -> MergeResult:
240
+ """Perform the actual git merge/rebase operation.
241
+
242
+ Steps:
243
+ 1. Verify worktree has no uncommitted changes (git status --porcelain)
244
+ 2. Verify branch exists and is not protected
245
+ 3. Checkout default branch in main repo
246
+ 4. Fetch from remote (git fetch)
247
+ 5. Pull with --ff-only (configurable)
248
+ 6. Merge or rebase the worktree branch
249
+ 7. Delete branch after merge (configurable)
250
+
251
+ Args:
252
+ ticket_id: The ticket ID
253
+ workspace: The workspace with worktree info
254
+ strategy: Merge strategy
255
+ default_branch: Pre-detected default branch name
256
+
257
+ Returns:
258
+ MergeResult with operation outcome
259
+ """
260
+ repo_path = WorkspaceService.get_repo_path()
261
+ worktree_path = Path(workspace.worktree_path)
262
+ branch_name = workspace.branch_name
263
+ merge_config = self._config.merge_config
264
+
265
+ start_time = time.time()
266
+ all_stdout = []
267
+ all_stderr = []
268
+
269
+ def record_output(label: str, result: subprocess.CompletedProcess) -> None:
270
+ """Helper to record command output."""
271
+ all_stdout.append(f"=== {label} ===\n{result.stdout}")
272
+ if result.stderr:
273
+ all_stderr.append(f"=== {label} ===\n{result.stderr}")
274
+
275
+ def make_failure(message: str, exit_code: int = 1) -> MergeResult:
276
+ """Helper to create a failure result with evidence."""
277
+ return MergeResult(
278
+ success=False,
279
+ message=message,
280
+ exit_code=exit_code,
281
+ stdout="\n".join(all_stdout),
282
+ stderr="\n".join(all_stderr),
283
+ default_branch=default_branch,
284
+ )
285
+
286
+ try:
287
+ # Step 1: Verify worktree has no uncommitted changes
288
+ # NOTE: This runs in WORKTREE to check worktree status
289
+ result = subprocess.run(
290
+ ["git", "status", "--porcelain"],
291
+ cwd=worktree_path, # <-- WORKTREE directory
292
+ capture_output=True,
293
+ text=True,
294
+ timeout=30,
295
+ )
296
+ record_output("git status --porcelain (worktree)", result)
297
+
298
+ if result.stdout.strip():
299
+ return make_failure("Worktree has uncommitted changes")
300
+
301
+ # Step 2: Ensure branch is not protected
302
+ if branch_name.lower() in self.PROTECTED_BRANCHES:
303
+ return make_failure(
304
+ f"Cannot merge from protected branch: {branch_name}"
305
+ )
306
+
307
+ # Step 3: Checkout default branch in main repo
308
+ # NOTE: All remaining git commands run in MAIN REPO, not worktree!
309
+ # This is critical: we merge the feature branch INTO the default branch.
310
+ result = subprocess.run(
311
+ ["git", "checkout", default_branch],
312
+ cwd=repo_path, # <-- MAIN REPO directory (NOT worktree!)
313
+ capture_output=True,
314
+ text=True,
315
+ timeout=60,
316
+ )
317
+ record_output(f"git checkout {default_branch}", result)
318
+
319
+ if result.returncode != 0:
320
+ return make_failure(
321
+ f"Failed to checkout {default_branch}", result.returncode
322
+ )
323
+
324
+ # Step 4: Fetch from remote (only if remote exists)
325
+ has_remote = self._has_remote_origin(repo_path)
326
+ if has_remote:
327
+ result = subprocess.run(
328
+ ["git", "fetch", "origin"],
329
+ cwd=repo_path,
330
+ capture_output=True,
331
+ text=True,
332
+ timeout=120,
333
+ )
334
+ record_output("git fetch origin", result)
335
+ # Don't fail on fetch error - network might be down
336
+ else:
337
+ all_stdout.append("=== Skipping fetch (no remote 'origin') ===")
338
+
339
+ # Step 5: Optional pull before merge (only if remote exists)
340
+ # Explicitly specify origin and branch to avoid pulling from wrong remote
341
+ pull_warning: str | None = None
342
+ if merge_config.pull_before_merge and has_remote:
343
+ result = subprocess.run(
344
+ ["git", "pull", "--ff-only", "origin", default_branch],
345
+ cwd=repo_path,
346
+ capture_output=True,
347
+ text=True,
348
+ timeout=120,
349
+ )
350
+ record_output(f"git pull --ff-only origin {default_branch}", result)
351
+
352
+ if result.returncode != 0:
353
+ if merge_config.require_pull_success:
354
+ return make_failure(
355
+ f"Failed to pull latest changes from origin/{default_branch} "
356
+ f"(require_pull_success=true). Set require_pull_success: false "
357
+ f"in config to allow local-only merge.",
358
+ result.returncode,
359
+ )
360
+ else:
361
+ # Pull failed but config allows continuing - track warning
362
+ pull_warning = (
363
+ f"Merged locally without pulling latest from origin/{default_branch}. "
364
+ f"May cause conflicts when pushing."
365
+ )
366
+ all_stderr.append(
367
+ f"=== WARNING: git pull failed but continuing "
368
+ f"(require_pull_success=false) ===\n{result.stderr}"
369
+ )
370
+ logger.warning(
371
+ f"Pull failed for {default_branch} but continuing due to "
372
+ f"require_pull_success=false: {result.stderr}"
373
+ )
374
+ elif not has_remote and merge_config.pull_before_merge:
375
+ # No remote but pull was configured - note this in warning
376
+ pull_warning = "Merged locally (no remote 'origin' configured)."
377
+
378
+ # Step 6: Perform merge or rebase
379
+ if strategy == MergeStrategy.MERGE:
380
+ result = subprocess.run(
381
+ [
382
+ "git",
383
+ "merge",
384
+ "--no-ff",
385
+ branch_name,
386
+ "-m",
387
+ f"Merge branch '{branch_name}'",
388
+ ],
389
+ cwd=repo_path,
390
+ capture_output=True,
391
+ text=True,
392
+ timeout=120,
393
+ )
394
+ record_output(f"git merge --no-ff {branch_name}", result)
395
+ else: # REBASE
396
+ result = subprocess.run(
397
+ ["git", "rebase", branch_name],
398
+ cwd=repo_path,
399
+ capture_output=True,
400
+ text=True,
401
+ timeout=120,
402
+ )
403
+ record_output(f"git rebase {branch_name}", result)
404
+
405
+ if result.returncode != 0:
406
+ # Abort merge/rebase on conflict
407
+ abort_cmd = (
408
+ ["git", "merge", "--abort"]
409
+ if strategy == MergeStrategy.MERGE
410
+ else ["git", "rebase", "--abort"]
411
+ )
412
+ subprocess.run(
413
+ abort_cmd, cwd=repo_path, timeout=30, capture_output=True
414
+ )
415
+
416
+ return make_failure(
417
+ f"Merge conflict or failure during {strategy.value}",
418
+ result.returncode,
419
+ )
420
+
421
+ # Step 7: Delete branch after merge (optional)
422
+ if merge_config.delete_branch_after_merge:
423
+ result = subprocess.run(
424
+ ["git", "branch", "-d", branch_name],
425
+ cwd=repo_path,
426
+ capture_output=True,
427
+ text=True,
428
+ timeout=30,
429
+ )
430
+ record_output(f"git branch -d {branch_name}", result)
431
+
432
+ duration_ms = int((time.time() - start_time) * 1000)
433
+
434
+ # Create evidence records (stdout, stderr, and meta)
435
+ evidence_ids = await self._create_merge_evidence(
436
+ ticket_id=ticket_id,
437
+ strategy=strategy,
438
+ branch=branch_name,
439
+ base_branch=default_branch,
440
+ exit_code=0,
441
+ duration_ms=duration_ms,
442
+ stdout="\n".join(all_stdout),
443
+ stderr="\n".join(all_stderr),
444
+ )
445
+
446
+ return MergeResult(
447
+ success=True,
448
+ message=f"Successfully merged branch '{branch_name}' into {default_branch}",
449
+ exit_code=0,
450
+ stdout="\n".join(all_stdout),
451
+ stderr="\n".join(all_stderr),
452
+ default_branch=default_branch,
453
+ evidence_ids=evidence_ids,
454
+ pull_warning=pull_warning,
455
+ )
456
+
457
+ except subprocess.TimeoutExpired:
458
+ all_stderr.append("[TIMEOUT]")
459
+ return MergeResult(
460
+ success=False,
461
+ message="Git operation timed out",
462
+ exit_code=-1,
463
+ stdout="\n".join(all_stdout),
464
+ stderr="\n".join(all_stderr),
465
+ default_branch=default_branch,
466
+ )
467
+ except Exception as e:
468
+ logger.exception(f"Merge failed for ticket {ticket_id}")
469
+ all_stderr.append(f"[EXCEPTION: {e}]")
470
+ return MergeResult(
471
+ success=False,
472
+ message=f"Merge failed: {str(e)}",
473
+ exit_code=-1,
474
+ stdout="\n".join(all_stdout),
475
+ stderr="\n".join(all_stderr),
476
+ default_branch=default_branch,
477
+ )
478
+
479
+ def _has_remote_origin(self, repo_path: Path) -> bool:
480
+ """Check if the repository has an 'origin' remote.
481
+
482
+ Args:
483
+ repo_path: Path to the repository
484
+
485
+ Returns:
486
+ True if 'origin' remote exists
487
+ """
488
+ try:
489
+ result = subprocess.run(
490
+ ["git", "remote", "get-url", "origin"],
491
+ cwd=repo_path,
492
+ capture_output=True,
493
+ timeout=10,
494
+ )
495
+ return result.returncode == 0
496
+ except Exception:
497
+ return False
498
+
499
+ def _detect_default_branch(self, repo_path: Path) -> str:
500
+ """Detect the default branch of the repository.
501
+
502
+ Tries:
503
+ 1. git symbolic-ref refs/remotes/origin/HEAD
504
+ 2. Fallback to 'main' if exists
505
+ 3. Fallback to 'master'
506
+
507
+ Args:
508
+ repo_path: Path to the repository
509
+
510
+ Returns:
511
+ Name of the default branch
512
+ """
513
+ # Try origin/HEAD (most reliable for remote-tracking repos)
514
+ try:
515
+ result = subprocess.run(
516
+ ["git", "symbolic-ref", "refs/remotes/origin/HEAD"],
517
+ cwd=repo_path,
518
+ capture_output=True,
519
+ text=True,
520
+ timeout=10,
521
+ )
522
+ if result.returncode == 0:
523
+ # Output is like "refs/remotes/origin/main"
524
+ ref = result.stdout.strip()
525
+ return ref.split("/")[-1]
526
+ except Exception:
527
+ pass
528
+
529
+ # Check if 'main' branch exists locally
530
+ result = subprocess.run(
531
+ ["git", "rev-parse", "--verify", "refs/heads/main"],
532
+ cwd=repo_path,
533
+ capture_output=True,
534
+ timeout=10,
535
+ )
536
+ if result.returncode == 0:
537
+ return "main"
538
+
539
+ # Fallback to 'master'
540
+ return "master"
541
+
542
+ async def _create_merge_evidence(
543
+ self,
544
+ ticket_id: str,
545
+ strategy: MergeStrategy,
546
+ branch: str,
547
+ base_branch: str,
548
+ exit_code: int,
549
+ duration_ms: int,
550
+ stdout: str,
551
+ stderr: str,
552
+ ) -> dict[str, str]:
553
+ """Create evidence records for the merge operation.
554
+
555
+ Creates three evidence records:
556
+ - MERGE_STDOUT: Combined stdout from all git commands
557
+ - MERGE_STDERR: Combined stderr from all git commands
558
+ - MERGE_META: JSON metadata with strategy, branches, exit_code, duration, evidence_ids
559
+
560
+ Args:
561
+ ticket_id: The ticket ID
562
+ strategy: Merge strategy used
563
+ branch: Worktree branch that was merged
564
+ base_branch: Default branch merged into
565
+ exit_code: Exit code of merge operation
566
+ duration_ms: Duration in milliseconds
567
+ stdout: Combined stdout from git commands
568
+ stderr: Combined stderr from git commands
569
+
570
+ Returns:
571
+ Dict with evidence IDs: {"stdout_id", "stderr_id", "meta_id"}
572
+ """
573
+ evidence_dir = get_evidence_dir("merge")
574
+ evidence_dir.mkdir(parents=True, exist_ok=True)
575
+
576
+ evidence_ids = {}
577
+
578
+ # Create stdout evidence
579
+ stdout_id = str(uuid.uuid4())
580
+ stdout_path = evidence_dir / f"{stdout_id}.stdout"
581
+ stdout_path.write_text(stdout, encoding="utf-8")
582
+ stdout_relpath = str(stdout_path)
583
+
584
+ stdout_evidence = Evidence(
585
+ id=stdout_id,
586
+ ticket_id=ticket_id,
587
+ job_id=stdout_id, # Use same ID as pseudo-job reference
588
+ kind=EvidenceKind.MERGE_STDOUT.value,
589
+ command=f"git {strategy.value}",
590
+ exit_code=exit_code,
591
+ stdout_path=stdout_relpath,
592
+ stderr_path=None,
593
+ )
594
+ self.db.add(stdout_evidence)
595
+ evidence_ids["stdout_id"] = stdout_id
596
+
597
+ # Create stderr evidence (only if there's content)
598
+ stderr_id = str(uuid.uuid4())
599
+ if stderr.strip():
600
+ stderr_path = evidence_dir / f"{stderr_id}.stderr"
601
+ stderr_path.write_text(stderr, encoding="utf-8")
602
+ stderr_relpath = str(stderr_path)
603
+
604
+ stderr_evidence = Evidence(
605
+ id=stderr_id,
606
+ ticket_id=ticket_id,
607
+ job_id=stderr_id,
608
+ kind=EvidenceKind.MERGE_STDERR.value,
609
+ command=f"git {strategy.value}",
610
+ exit_code=exit_code,
611
+ stdout_path=stderr_relpath, # stderr content stored in stdout_path field
612
+ stderr_path=None,
613
+ )
614
+ self.db.add(stderr_evidence)
615
+ evidence_ids["stderr_id"] = stderr_id
616
+
617
+ # Create meta evidence (JSON)
618
+ meta_id = str(uuid.uuid4())
619
+ meta = {
620
+ "strategy": strategy.value,
621
+ "worktree_branch": branch,
622
+ "base_branch": base_branch,
623
+ "exit_code": exit_code,
624
+ "duration_ms": duration_ms,
625
+ "success": exit_code == 0,
626
+ "evidence_ids": {
627
+ "stdout_id": stdout_id,
628
+ "stderr_id": stderr_id if stderr.strip() else None,
629
+ },
630
+ }
631
+ meta_path = evidence_dir / f"{meta_id}.meta.json"
632
+ meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
633
+ meta_relpath = str(meta_path)
634
+
635
+ meta_evidence = Evidence(
636
+ id=meta_id,
637
+ ticket_id=ticket_id,
638
+ job_id=meta_id,
639
+ kind=EvidenceKind.MERGE_META.value,
640
+ command="merge_metadata",
641
+ exit_code=exit_code,
642
+ stdout_path=meta_relpath,
643
+ stderr_path=None,
644
+ )
645
+ self.db.add(meta_evidence)
646
+ evidence_ids["meta_id"] = meta_id
647
+
648
+ await self.db.flush()
649
+ return evidence_ids
650
+
651
+ async def _cleanup_worktree(
652
+ self,
653
+ ticket_id: str,
654
+ workspace: Workspace,
655
+ actor_id: str,
656
+ ) -> None:
657
+ """Clean up a worktree after successful merge.
658
+
659
+ Args:
660
+ ticket_id: The ticket ID
661
+ workspace: The workspace to clean up
662
+ actor_id: Actor ID for event
663
+ """
664
+ from app.services.cleanup_service import CleanupService
665
+
666
+ cleanup_service = CleanupService(self.db)
667
+ await cleanup_service.delete_worktree(
668
+ workspace=workspace,
669
+ ticket_id=ticket_id,
670
+ actor_id=actor_id,
671
+ delete_branch=True, # Safe to delete since merge succeeded
672
+ )
673
+
674
+ async def _create_event(
675
+ self,
676
+ ticket_id: str,
677
+ event_type: EventType,
678
+ reason: str,
679
+ payload: dict,
680
+ actor_id: str,
681
+ ) -> TicketEvent:
682
+ """Create a ticket event.
683
+
684
+ Args:
685
+ ticket_id: The ticket ID
686
+ event_type: Type of event
687
+ reason: Reason for the event
688
+ payload: Event payload
689
+ actor_id: Actor ID
690
+
691
+ Returns:
692
+ The created TicketEvent
693
+ """
694
+ event = TicketEvent(
695
+ ticket_id=ticket_id,
696
+ event_type=event_type.value,
697
+ from_state=TicketState.DONE.value,
698
+ to_state=TicketState.DONE.value,
699
+ actor_type=ActorType.SYSTEM.value,
700
+ actor_id=actor_id,
701
+ reason=reason,
702
+ payload_json=json.dumps(payload),
703
+ )
704
+ self.db.add(event)
705
+ await self.db.flush()
706
+ return event
707
+
708
+ async def get_merge_status(self, ticket_id: str) -> dict:
709
+ """Get the merge status for a ticket.
710
+
711
+ Args:
712
+ ticket_id: The ticket ID
713
+
714
+ Returns:
715
+ Dict with merge status info including:
716
+ - can_merge: Whether merge is possible
717
+ - is_merged: Whether already merged
718
+ - has_approved_revision: Whether approval exists
719
+ - workspace: Worktree info if active
720
+ - last_merge_attempt: Most recent merge event
721
+ """
722
+ result = await self.db.execute(
723
+ select(Ticket)
724
+ .where(Ticket.id == ticket_id)
725
+ .options(
726
+ selectinload(Ticket.workspace),
727
+ selectinload(Ticket.events),
728
+ selectinload(Ticket.revisions),
729
+ )
730
+ )
731
+ ticket = result.scalar_one_or_none()
732
+ if ticket is None:
733
+ raise ResourceNotFoundError("Ticket", ticket_id)
734
+
735
+ # Check for merge events
736
+ merge_events = [
737
+ e
738
+ for e in ticket.events
739
+ if e.event_type
740
+ in [
741
+ EventType.MERGE_REQUESTED.value,
742
+ EventType.MERGE_SUCCEEDED.value,
743
+ EventType.MERGE_FAILED.value,
744
+ ]
745
+ ]
746
+
747
+ is_merged = any(
748
+ e.event_type == EventType.MERGE_SUCCEEDED.value for e in merge_events
749
+ )
750
+ last_merge_attempt = max(merge_events, key=lambda e: e.created_at, default=None)
751
+
752
+ # Check for approved revision
753
+ has_approved_revision = any(
754
+ r.status == RevisionStatus.APPROVED.value for r in ticket.revisions
755
+ )
756
+
757
+ # Workspace info
758
+ workspace_info = None
759
+ if ticket.workspace and ticket.workspace.is_active:
760
+ workspace_info = {
761
+ "worktree_path": ticket.workspace.worktree_path,
762
+ "branch_name": ticket.workspace.branch_name,
763
+ }
764
+
765
+ return {
766
+ "ticket_id": ticket_id,
767
+ "can_merge": (
768
+ ticket.state == TicketState.DONE.value
769
+ and has_approved_revision
770
+ and workspace_info is not None
771
+ and not is_merged
772
+ ),
773
+ "is_merged": is_merged,
774
+ "has_approved_revision": has_approved_revision,
775
+ "workspace": workspace_info,
776
+ "last_merge_attempt": {
777
+ "event_type": last_merge_attempt.event_type,
778
+ "reason": last_merge_attempt.reason,
779
+ "created_at": last_merge_attempt.created_at.isoformat(),
780
+ "payload": last_merge_attempt.get_payload(),
781
+ }
782
+ if last_merge_attempt
783
+ else None,
784
+ }