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