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.
- package/app/backend/.env.example +9 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_patch.txt +195 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_stat.txt +6 -0
- package/app/backend/CURL_EXAMPLES.md +335 -0
- package/app/backend/ENV_SETUP.md +65 -0
- package/app/backend/alembic/env.py +71 -0
- package/app/backend/alembic/script.py.mako +28 -0
- package/app/backend/alembic/versions/001_initial_schema.py +104 -0
- package/app/backend/alembic/versions/002_add_jobs_table.py +52 -0
- package/app/backend/alembic/versions/003_add_workspace_table.py +48 -0
- package/app/backend/alembic/versions/004_add_evidence_table.py +56 -0
- package/app/backend/alembic/versions/005_add_verification_commands.py +32 -0
- package/app/backend/alembic/versions/006_add_planner_lock_table.py +39 -0
- package/app/backend/alembic/versions/007_add_revision_review_tables.py +126 -0
- package/app/backend/alembic/versions/008_add_revision_idempotency_and_traceability.py +52 -0
- package/app/backend/alembic/versions/009_add_job_health_fields.py +46 -0
- package/app/backend/alembic/versions/010_add_review_comment_line_content.py +36 -0
- package/app/backend/alembic/versions/011_add_analysis_cache.py +47 -0
- package/app/backend/alembic/versions/012_add_boards_table.py +102 -0
- package/app/backend/alembic/versions/013_add_ticket_blocking.py +45 -0
- package/app/backend/alembic/versions/014_add_agent_sessions.py +220 -0
- package/app/backend/alembic/versions/015_add_ticket_sort_order.py +33 -0
- package/app/backend/alembic/versions/03220f0b93ae_add_pr_fields_to_ticket.py +49 -0
- package/app/backend/alembic/versions/0c2d89fff3b1_seed_board_configs_from_yaml.py +206 -0
- package/app/backend/alembic/versions/3348e5cf54c1_add_merge_checklist_table.py +67 -0
- package/app/backend/alembic/versions/357c780ee445_add_goal_status.py +34 -0
- package/app/backend/alembic/versions/553340b7e26c_add_autonomy_fields_to_goal.py +65 -0
- package/app/backend/alembic/versions/774dc335c679_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/7b307e847cbd_merge_heads.py +23 -0
- package/app/backend/alembic/versions/82ecd978cc70_add_missing_indexes.py +48 -0
- package/app/backend/alembic/versions/8ef5054dc280_add_normalized_log_entries.py +173 -0
- package/app/backend/alembic/versions/8f3e2bd8ea3b_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/9d17f0698d3b_add_config_column_to_boards_table.py +30 -0
- package/app/backend/alembic/versions/add_agent_conversation_history.py +72 -0
- package/app/backend/alembic/versions/add_job_variant.py +34 -0
- package/app/backend/alembic/versions/add_performance_indexes.py +95 -0
- package/app/backend/alembic/versions/add_repos_and_board_repos.py +174 -0
- package/app/backend/alembic/versions/add_session_id_to_jobs.py +27 -0
- package/app/backend/alembic/versions/add_sqlite_backend_tables.py +104 -0
- package/app/backend/alembic/versions/b10fb0b62240_add_diff_content_to_revisions.py +34 -0
- package/app/backend/alembic.ini +89 -0
- package/app/backend/app/__init__.py +3 -0
- package/app/backend/app/data_dir.py +85 -0
- package/app/backend/app/database.py +70 -0
- package/app/backend/app/database_sync.py +64 -0
- package/app/backend/app/dependencies/__init__.py +5 -0
- package/app/backend/app/dependencies/auth.py +80 -0
- package/app/backend/app/dependencies.py +43 -0
- package/app/backend/app/exceptions.py +178 -0
- package/app/backend/app/executors/__init__.py +1 -0
- package/app/backend/app/executors/adapters/__init__.py +1 -0
- package/app/backend/app/executors/adapters/aider.py +152 -0
- package/app/backend/app/executors/adapters/amazon_q.py +103 -0
- package/app/backend/app/executors/adapters/amp.py +123 -0
- package/app/backend/app/executors/adapters/claude.py +177 -0
- package/app/backend/app/executors/adapters/cline.py +127 -0
- package/app/backend/app/executors/adapters/codex.py +167 -0
- package/app/backend/app/executors/adapters/copilot.py +202 -0
- package/app/backend/app/executors/adapters/cursor.py +87 -0
- package/app/backend/app/executors/adapters/droid.py +123 -0
- package/app/backend/app/executors/adapters/gemini.py +132 -0
- package/app/backend/app/executors/adapters/goose.py +131 -0
- package/app/backend/app/executors/adapters/opencode.py +123 -0
- package/app/backend/app/executors/adapters/qwen.py +123 -0
- package/app/backend/app/executors/plugins/__init__.py +1 -0
- package/app/backend/app/executors/registry.py +202 -0
- package/app/backend/app/executors/spec.py +226 -0
- package/app/backend/app/main.py +486 -0
- package/app/backend/app/middleware/__init__.py +13 -0
- package/app/backend/app/middleware/idempotency.py +426 -0
- package/app/backend/app/middleware/rate_limit.py +312 -0
- package/app/backend/app/middleware/security_headers.py +43 -0
- package/app/backend/app/middleware/timeout.py +37 -0
- package/app/backend/app/models/__init__.py +56 -0
- package/app/backend/app/models/agent_conversation_history.py +56 -0
- package/app/backend/app/models/agent_session.py +127 -0
- package/app/backend/app/models/analysis_cache.py +49 -0
- package/app/backend/app/models/base.py +9 -0
- package/app/backend/app/models/board.py +79 -0
- package/app/backend/app/models/board_repo.py +68 -0
- package/app/backend/app/models/cost_budget.py +42 -0
- package/app/backend/app/models/enums.py +40 -0
- package/app/backend/app/models/evidence.py +132 -0
- package/app/backend/app/models/goal.py +102 -0
- package/app/backend/app/models/idempotency_entry.py +30 -0
- package/app/backend/app/models/job.py +163 -0
- package/app/backend/app/models/job_queue.py +39 -0
- package/app/backend/app/models/kv_store.py +28 -0
- package/app/backend/app/models/merge_checklist.py +87 -0
- package/app/backend/app/models/normalized_log.py +100 -0
- package/app/backend/app/models/planner_lock.py +43 -0
- package/app/backend/app/models/rate_limit_entry.py +25 -0
- package/app/backend/app/models/repo.py +66 -0
- package/app/backend/app/models/review_comment.py +91 -0
- package/app/backend/app/models/review_summary.py +69 -0
- package/app/backend/app/models/revision.py +130 -0
- package/app/backend/app/models/ticket.py +223 -0
- package/app/backend/app/models/ticket_event.py +83 -0
- package/app/backend/app/models/user.py +47 -0
- package/app/backend/app/models/workspace.py +71 -0
- package/app/backend/app/redis_client.py +119 -0
- package/app/backend/app/routers/__init__.py +29 -0
- package/app/backend/app/routers/agents.py +296 -0
- package/app/backend/app/routers/auth.py +94 -0
- package/app/backend/app/routers/board.py +885 -0
- package/app/backend/app/routers/dashboard.py +351 -0
- package/app/backend/app/routers/debug.py +528 -0
- package/app/backend/app/routers/evidence.py +96 -0
- package/app/backend/app/routers/executors.py +324 -0
- package/app/backend/app/routers/goals.py +574 -0
- package/app/backend/app/routers/jobs.py +448 -0
- package/app/backend/app/routers/maintenance.py +172 -0
- package/app/backend/app/routers/merge.py +360 -0
- package/app/backend/app/routers/planner.py +537 -0
- package/app/backend/app/routers/pull_requests.py +382 -0
- package/app/backend/app/routers/repos.py +263 -0
- package/app/backend/app/routers/revisions.py +939 -0
- package/app/backend/app/routers/settings.py +267 -0
- package/app/backend/app/routers/tickets.py +2003 -0
- package/app/backend/app/routers/webhooks.py +143 -0
- package/app/backend/app/routers/websocket.py +249 -0
- package/app/backend/app/schemas/__init__.py +109 -0
- package/app/backend/app/schemas/board.py +87 -0
- package/app/backend/app/schemas/common.py +33 -0
- package/app/backend/app/schemas/evidence.py +87 -0
- package/app/backend/app/schemas/goal.py +90 -0
- package/app/backend/app/schemas/job.py +97 -0
- package/app/backend/app/schemas/merge.py +139 -0
- package/app/backend/app/schemas/planner.py +500 -0
- package/app/backend/app/schemas/repo.py +187 -0
- package/app/backend/app/schemas/review.py +137 -0
- package/app/backend/app/schemas/revision.py +114 -0
- package/app/backend/app/schemas/ticket.py +238 -0
- package/app/backend/app/schemas/ticket_event.py +72 -0
- package/app/backend/app/schemas/workspace.py +19 -0
- package/app/backend/app/services/__init__.py +31 -0
- package/app/backend/app/services/agent_memory_service.py +223 -0
- package/app/backend/app/services/agent_registry.py +346 -0
- package/app/backend/app/services/agent_session_manager.py +318 -0
- package/app/backend/app/services/agent_session_service.py +219 -0
- package/app/backend/app/services/agent_tools.py +379 -0
- package/app/backend/app/services/auth_service.py +98 -0
- package/app/backend/app/services/autonomy_service.py +380 -0
- package/app/backend/app/services/board_repo_service.py +201 -0
- package/app/backend/app/services/board_service.py +326 -0
- package/app/backend/app/services/cleanup_service.py +1085 -0
- package/app/backend/app/services/config_service.py +908 -0
- package/app/backend/app/services/context_gatherer.py +557 -0
- package/app/backend/app/services/cost_tracking_service.py +293 -0
- package/app/backend/app/services/cursor_log_normalizer.py +536 -0
- package/app/backend/app/services/delivery_pipeline.py +440 -0
- package/app/backend/app/services/executor_service.py +634 -0
- package/app/backend/app/services/git_host/__init__.py +11 -0
- package/app/backend/app/services/git_host/factory.py +87 -0
- package/app/backend/app/services/git_host/github.py +270 -0
- package/app/backend/app/services/git_host/gitlab.py +194 -0
- package/app/backend/app/services/git_host/protocol.py +75 -0
- package/app/backend/app/services/git_merge_simple.py +346 -0
- package/app/backend/app/services/git_ops.py +384 -0
- package/app/backend/app/services/github_service.py +233 -0
- package/app/backend/app/services/goal_service.py +113 -0
- package/app/backend/app/services/job_service.py +423 -0
- package/app/backend/app/services/job_watchdog_service.py +424 -0
- package/app/backend/app/services/langchain_adapter.py +122 -0
- package/app/backend/app/services/llm_provider_clients.py +351 -0
- package/app/backend/app/services/llm_service.py +285 -0
- package/app/backend/app/services/log_normalizer.py +342 -0
- package/app/backend/app/services/log_stream_service.py +276 -0
- package/app/backend/app/services/merge_checklist_service.py +264 -0
- package/app/backend/app/services/merge_service.py +784 -0
- package/app/backend/app/services/orchestrator_log.py +84 -0
- package/app/backend/app/services/planner_service.py +1662 -0
- package/app/backend/app/services/planner_tick_sync.py +1040 -0
- package/app/backend/app/services/queued_message_service.py +156 -0
- package/app/backend/app/services/reliability_wrapper.py +389 -0
- package/app/backend/app/services/repo_discovery_service.py +318 -0
- package/app/backend/app/services/review_service.py +334 -0
- package/app/backend/app/services/revision_service.py +389 -0
- package/app/backend/app/services/safe_autopilot.py +510 -0
- package/app/backend/app/services/sqlite_worker.py +372 -0
- package/app/backend/app/services/task_dispatch.py +135 -0
- package/app/backend/app/services/ticket_generation_service.py +1781 -0
- package/app/backend/app/services/ticket_service.py +486 -0
- package/app/backend/app/services/udar_planner_service.py +1007 -0
- package/app/backend/app/services/webhook_service.py +126 -0
- package/app/backend/app/services/workspace_service.py +465 -0
- package/app/backend/app/services/worktree_file_service.py +92 -0
- package/app/backend/app/services/worktree_validator.py +213 -0
- package/app/backend/app/sqlite_kv.py +278 -0
- package/app/backend/app/state_machine.py +128 -0
- package/app/backend/app/templates/__init__.py +5 -0
- package/app/backend/app/templates/registry.py +243 -0
- package/app/backend/app/utils/__init__.py +5 -0
- package/app/backend/app/utils/artifact_reader.py +87 -0
- package/app/backend/app/utils/circuit_breaker.py +229 -0
- package/app/backend/app/utils/db_retry.py +136 -0
- package/app/backend/app/utils/ignored_fields.py +123 -0
- package/app/backend/app/utils/validators.py +54 -0
- package/app/backend/app/websocket/__init__.py +5 -0
- package/app/backend/app/websocket/manager.py +179 -0
- package/app/backend/app/websocket/state_tracker.py +113 -0
- package/app/backend/app/worker.py +3190 -0
- package/app/backend/calculator_tickets.json +40 -0
- package/app/backend/canary_tests.sh +591 -0
- package/app/backend/celerybeat-schedule +0 -0
- package/app/backend/celerybeat-schedule-shm +0 -0
- package/app/backend/celerybeat-schedule-wal +0 -0
- package/app/backend/logs/.gitkeep +3 -0
- package/app/backend/multiplication_division_implementation_tickets.json +55 -0
- package/app/backend/multiplication_division_tickets.json +42 -0
- package/app/backend/pyproject.toml +45 -0
- package/app/backend/requirements-dev.txt +8 -0
- package/app/backend/requirements.txt +20 -0
- package/app/backend/run.sh +30 -0
- package/app/backend/run_with_logs.sh +10 -0
- package/app/backend/scientific_calculator_tickets.json +40 -0
- package/app/backend/scripts/extract_openapi.py +21 -0
- package/app/backend/scripts/seed_demo.py +187 -0
- package/app/backend/setup_demo_review.py +302 -0
- package/app/backend/test_actual_parse.py +41 -0
- package/app/backend/test_agent_streaming.py +61 -0
- package/app/backend/test_parse.py +51 -0
- package/app/backend/test_streaming.py +51 -0
- package/app/backend/test_subprocess_streaming.py +50 -0
- package/app/backend/tests/__init__.py +1 -0
- package/app/backend/tests/conftest.py +46 -0
- package/app/backend/tests/test_auth.py +341 -0
- package/app/backend/tests/test_autonomy_service.py +391 -0
- package/app/backend/tests/test_cleanup_service_safety.py +417 -0
- package/app/backend/tests/test_middleware.py +279 -0
- package/app/backend/tests/test_planner_providers.py +290 -0
- package/app/backend/tests/test_planner_unblock.py +183 -0
- package/app/backend/tests/test_revision_invariants.py +618 -0
- package/app/backend/tests/test_sqlite_kv.py +290 -0
- package/app/backend/tests/test_sqlite_worker.py +353 -0
- package/app/backend/tests/test_task_dispatch.py +100 -0
- package/app/backend/tests/test_ticket_validation.py +304 -0
- package/app/backend/tests/test_udar_agent.py +693 -0
- package/app/backend/tests/test_webhook_service.py +184 -0
- package/app/backend/tickets_output.json +59 -0
- package/app/backend/user_management_tickets.json +50 -0
- package/app/backend/uvicorn.log +0 -0
- package/app/draft.yaml +313 -0
- package/app/frontend/dist/assets/index-LcjCczu5.js +155 -0
- package/app/frontend/dist/assets/index-_FP_279e.css +1 -0
- package/app/frontend/dist/index.html +14 -0
- package/app/frontend/dist/vite.svg +1 -0
- package/app/frontend/package.json +101 -0
- package/bin/cli.js +527 -0
- 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
|
+
}
|