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,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
|