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,318 @@
|
|
|
1
|
+
"""Service for discovering and managing git repositories."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import uuid
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import git
|
|
9
|
+
from sqlalchemy import select
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
|
+
|
|
12
|
+
from app.models.repo import Repo
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class DiscoveredRepo:
|
|
17
|
+
"""Represents a discovered git repository."""
|
|
18
|
+
|
|
19
|
+
path: str
|
|
20
|
+
name: str
|
|
21
|
+
display_name: str
|
|
22
|
+
default_branch: str | None = None
|
|
23
|
+
remote_url: str | None = None
|
|
24
|
+
is_valid: bool = True
|
|
25
|
+
error_message: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class RepoValidation:
|
|
30
|
+
"""Result of repository path validation."""
|
|
31
|
+
|
|
32
|
+
is_valid: bool
|
|
33
|
+
path: str
|
|
34
|
+
error_message: str | None = None
|
|
35
|
+
metadata: DiscoveredRepo | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RepoDiscoveryService:
|
|
39
|
+
"""Service to discover and register git repositories."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, db: AsyncSession):
|
|
42
|
+
self.db = db
|
|
43
|
+
|
|
44
|
+
# Patterns to exclude from discovery
|
|
45
|
+
EXCLUDE_PATTERNS = {
|
|
46
|
+
"node_modules",
|
|
47
|
+
".git",
|
|
48
|
+
"vendor",
|
|
49
|
+
"venv",
|
|
50
|
+
".venv",
|
|
51
|
+
"env",
|
|
52
|
+
"__pycache__",
|
|
53
|
+
".pytest_cache",
|
|
54
|
+
"dist",
|
|
55
|
+
"build",
|
|
56
|
+
".tox",
|
|
57
|
+
".eggs",
|
|
58
|
+
"target", # Rust
|
|
59
|
+
".gradle", # Gradle
|
|
60
|
+
".mvn", # Maven
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async def discover_repos(
|
|
64
|
+
self,
|
|
65
|
+
search_paths: list[str],
|
|
66
|
+
max_depth: int = 3,
|
|
67
|
+
exclude_patterns: set[str] | None = None,
|
|
68
|
+
) -> list[DiscoveredRepo]:
|
|
69
|
+
"""
|
|
70
|
+
Scan directories for git repositories.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
search_paths: List of directories to scan
|
|
74
|
+
max_depth: How deep to recurse (default 3)
|
|
75
|
+
exclude_patterns: Additional patterns to exclude
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
List of discovered git repositories
|
|
79
|
+
"""
|
|
80
|
+
if exclude_patterns is None:
|
|
81
|
+
exclude_patterns = self.EXCLUDE_PATTERNS.copy()
|
|
82
|
+
else:
|
|
83
|
+
exclude_patterns = self.EXCLUDE_PATTERNS | exclude_patterns
|
|
84
|
+
|
|
85
|
+
discovered = []
|
|
86
|
+
|
|
87
|
+
for search_path in search_paths:
|
|
88
|
+
# Expand user path (~)
|
|
89
|
+
expanded_path = os.path.expanduser(search_path)
|
|
90
|
+
path_obj = Path(expanded_path).resolve()
|
|
91
|
+
|
|
92
|
+
if not path_obj.exists():
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
# Walk directory tree
|
|
96
|
+
for root, dirs, _ in os.walk(path_obj, topdown=True):
|
|
97
|
+
# Calculate current depth
|
|
98
|
+
rel_path = Path(root).relative_to(path_obj)
|
|
99
|
+
depth = len(rel_path.parts) if rel_path != Path(".") else 0
|
|
100
|
+
|
|
101
|
+
# Prune search if too deep
|
|
102
|
+
if depth >= max_depth:
|
|
103
|
+
dirs.clear()
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
# Prune excluded directories
|
|
107
|
+
dirs[:] = [d for d in dirs if d not in exclude_patterns]
|
|
108
|
+
|
|
109
|
+
# Check if this directory is a git repo
|
|
110
|
+
git_dir = Path(root) / ".git"
|
|
111
|
+
if git_dir.exists():
|
|
112
|
+
# Found a git repo
|
|
113
|
+
repo_path = str(Path(root).resolve())
|
|
114
|
+
validation = self._validate_repo_path_sync(repo_path)
|
|
115
|
+
|
|
116
|
+
if validation.is_valid and validation.metadata:
|
|
117
|
+
discovered.append(validation.metadata)
|
|
118
|
+
|
|
119
|
+
# Don't recurse into git repos (avoid submodules)
|
|
120
|
+
dirs.clear()
|
|
121
|
+
|
|
122
|
+
return discovered
|
|
123
|
+
|
|
124
|
+
async def validate_repo_path(self, path: str) -> RepoValidation:
|
|
125
|
+
"""
|
|
126
|
+
Validate a path is a valid git repository.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
RepoValidation with is_valid, error_message, and metadata
|
|
130
|
+
"""
|
|
131
|
+
return self._validate_repo_path_sync(path)
|
|
132
|
+
|
|
133
|
+
def _validate_repo_path_sync(self, path: str) -> RepoValidation:
|
|
134
|
+
"""Synchronous version of validate_repo_path (for use in discover_repos)."""
|
|
135
|
+
try:
|
|
136
|
+
# Expand and resolve path
|
|
137
|
+
expanded = os.path.expanduser(path)
|
|
138
|
+
path_obj = Path(expanded).resolve()
|
|
139
|
+
|
|
140
|
+
# Check if path exists
|
|
141
|
+
if not path_obj.exists():
|
|
142
|
+
return RepoValidation(
|
|
143
|
+
is_valid=False,
|
|
144
|
+
path=str(path_obj),
|
|
145
|
+
error_message=f"Path does not exist: {path_obj}",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Check if it's a directory
|
|
149
|
+
if not path_obj.is_dir():
|
|
150
|
+
return RepoValidation(
|
|
151
|
+
is_valid=False,
|
|
152
|
+
path=str(path_obj),
|
|
153
|
+
error_message=f"Path is not a directory: {path_obj}",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Try to open as git repo
|
|
157
|
+
try:
|
|
158
|
+
repo = git.Repo(str(path_obj))
|
|
159
|
+
except git.InvalidGitRepositoryError:
|
|
160
|
+
return RepoValidation(
|
|
161
|
+
is_valid=False,
|
|
162
|
+
path=str(path_obj),
|
|
163
|
+
error_message=f"Path is not a git repository: {path_obj}",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Extract metadata
|
|
167
|
+
name = path_obj.name
|
|
168
|
+
display_name = name
|
|
169
|
+
|
|
170
|
+
# Get default branch
|
|
171
|
+
try:
|
|
172
|
+
default_branch = repo.active_branch.name
|
|
173
|
+
except Exception:
|
|
174
|
+
# Detached HEAD or other issue
|
|
175
|
+
default_branch = "main"
|
|
176
|
+
|
|
177
|
+
# Get remote URL
|
|
178
|
+
remote_url = None
|
|
179
|
+
try:
|
|
180
|
+
if repo.remotes:
|
|
181
|
+
remote_url = repo.remotes.origin.url
|
|
182
|
+
except Exception:
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
metadata = DiscoveredRepo(
|
|
186
|
+
path=str(path_obj),
|
|
187
|
+
name=name,
|
|
188
|
+
display_name=display_name,
|
|
189
|
+
default_branch=default_branch,
|
|
190
|
+
remote_url=remote_url,
|
|
191
|
+
is_valid=True,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
return RepoValidation(
|
|
195
|
+
is_valid=True,
|
|
196
|
+
path=str(path_obj),
|
|
197
|
+
metadata=metadata,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
except Exception as e:
|
|
201
|
+
return RepoValidation(
|
|
202
|
+
is_valid=False,
|
|
203
|
+
path=path,
|
|
204
|
+
error_message=f"Validation error: {str(e)}",
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
async def register_repo(
|
|
208
|
+
self,
|
|
209
|
+
path: str,
|
|
210
|
+
display_name: str | None = None,
|
|
211
|
+
setup_script: str | None = None,
|
|
212
|
+
cleanup_script: str | None = None,
|
|
213
|
+
dev_server_script: str | None = None,
|
|
214
|
+
) -> Repo:
|
|
215
|
+
"""
|
|
216
|
+
Register a repository in the global registry.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
path: Filesystem path to git repository
|
|
220
|
+
display_name: Optional user-friendly name
|
|
221
|
+
setup_script: Optional setup script
|
|
222
|
+
cleanup_script: Optional cleanup script
|
|
223
|
+
dev_server_script: Optional dev server script
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
Created Repo model
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
ValueError: If path is invalid or repo already exists
|
|
230
|
+
"""
|
|
231
|
+
# Validate path
|
|
232
|
+
validation = await self.validate_repo_path(path)
|
|
233
|
+
if not validation.is_valid:
|
|
234
|
+
raise ValueError(validation.error_message or "Invalid repository path")
|
|
235
|
+
|
|
236
|
+
if not validation.metadata:
|
|
237
|
+
raise ValueError("Repository validation returned no metadata")
|
|
238
|
+
|
|
239
|
+
# Check if repo already exists
|
|
240
|
+
result = await self.db.execute(select(Repo).where(Repo.path == validation.path))
|
|
241
|
+
existing = result.scalar_one_or_none()
|
|
242
|
+
if existing:
|
|
243
|
+
raise ValueError(f"Repository already registered: {validation.path}")
|
|
244
|
+
|
|
245
|
+
# Create repo
|
|
246
|
+
metadata = validation.metadata
|
|
247
|
+
repo = Repo(
|
|
248
|
+
id=str(uuid.uuid4()),
|
|
249
|
+
path=validation.path,
|
|
250
|
+
name=metadata.name,
|
|
251
|
+
display_name=display_name or metadata.display_name,
|
|
252
|
+
setup_script=setup_script,
|
|
253
|
+
cleanup_script=cleanup_script,
|
|
254
|
+
dev_server_script=dev_server_script,
|
|
255
|
+
default_branch=metadata.default_branch,
|
|
256
|
+
remote_url=metadata.remote_url,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
self.db.add(repo)
|
|
260
|
+
await self.db.commit()
|
|
261
|
+
await self.db.refresh(repo)
|
|
262
|
+
|
|
263
|
+
return repo
|
|
264
|
+
|
|
265
|
+
async def get_repo_by_id(self, repo_id: str) -> Repo | None:
|
|
266
|
+
"""Get a repo by its ID."""
|
|
267
|
+
result = await self.db.execute(select(Repo).where(Repo.id == repo_id))
|
|
268
|
+
return result.scalar_one_or_none()
|
|
269
|
+
|
|
270
|
+
async def get_repo_by_path(self, path: str) -> Repo | None:
|
|
271
|
+
"""Get a repo by its path."""
|
|
272
|
+
# Normalize path
|
|
273
|
+
expanded = os.path.expanduser(path)
|
|
274
|
+
normalized = str(Path(expanded).resolve())
|
|
275
|
+
|
|
276
|
+
result = await self.db.execute(select(Repo).where(Repo.path == normalized))
|
|
277
|
+
return result.scalar_one_or_none()
|
|
278
|
+
|
|
279
|
+
async def get_all_repos(self) -> list[Repo]:
|
|
280
|
+
"""Get all registered repos."""
|
|
281
|
+
result = await self.db.execute(select(Repo).order_by(Repo.created_at.desc()))
|
|
282
|
+
return list(result.scalars().all())
|
|
283
|
+
|
|
284
|
+
async def update_repo(
|
|
285
|
+
self,
|
|
286
|
+
repo_id: str,
|
|
287
|
+
display_name: str | None = None,
|
|
288
|
+
setup_script: str | None = None,
|
|
289
|
+
cleanup_script: str | None = None,
|
|
290
|
+
dev_server_script: str | None = None,
|
|
291
|
+
) -> Repo:
|
|
292
|
+
"""Update a repo's configuration."""
|
|
293
|
+
repo = await self.get_repo_by_id(repo_id)
|
|
294
|
+
if not repo:
|
|
295
|
+
raise ValueError(f"Repo not found: {repo_id}")
|
|
296
|
+
|
|
297
|
+
if display_name is not None:
|
|
298
|
+
repo.display_name = display_name
|
|
299
|
+
if setup_script is not None:
|
|
300
|
+
repo.setup_script = setup_script
|
|
301
|
+
if cleanup_script is not None:
|
|
302
|
+
repo.cleanup_script = cleanup_script
|
|
303
|
+
if dev_server_script is not None:
|
|
304
|
+
repo.dev_server_script = dev_server_script
|
|
305
|
+
|
|
306
|
+
await self.db.commit()
|
|
307
|
+
await self.db.refresh(repo)
|
|
308
|
+
|
|
309
|
+
return repo
|
|
310
|
+
|
|
311
|
+
async def delete_repo(self, repo_id: str) -> None:
|
|
312
|
+
"""Delete a repo from the registry."""
|
|
313
|
+
repo = await self.get_repo_by_id(repo_id)
|
|
314
|
+
if not repo:
|
|
315
|
+
raise ValueError(f"Repo not found: {repo_id}")
|
|
316
|
+
|
|
317
|
+
await self.db.delete(repo)
|
|
318
|
+
await self.db.commit()
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Service layer for Review operations (comments and summaries)."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import select
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
from sqlalchemy.orm import selectinload
|
|
8
|
+
|
|
9
|
+
from app.exceptions import ResourceNotFoundError, ValidationError
|
|
10
|
+
from app.models.review_comment import AuthorType, ReviewComment
|
|
11
|
+
from app.models.review_summary import ReviewDecision, ReviewSummary
|
|
12
|
+
from app.models.revision import Revision, RevisionStatus
|
|
13
|
+
from app.schemas.review import FeedbackBundle, FeedbackComment
|
|
14
|
+
from app.services.revision_service import compute_anchor
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ReviewService:
|
|
20
|
+
"""Service class for Review business logic (comments and summaries)."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, db: AsyncSession):
|
|
23
|
+
self.db = db
|
|
24
|
+
|
|
25
|
+
# ==================== Comment Operations ====================
|
|
26
|
+
|
|
27
|
+
async def add_comment(
|
|
28
|
+
self,
|
|
29
|
+
revision_id: str,
|
|
30
|
+
file_path: str,
|
|
31
|
+
line_number: int,
|
|
32
|
+
body: str,
|
|
33
|
+
author_type: AuthorType = AuthorType.HUMAN,
|
|
34
|
+
hunk_header: str | None = None,
|
|
35
|
+
line_content: str | None = None,
|
|
36
|
+
) -> ReviewComment:
|
|
37
|
+
"""Add an inline comment to a revision.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
revision_id: The UUID of the revision
|
|
41
|
+
file_path: Path to the file being commented on
|
|
42
|
+
line_number: Line number in the new file
|
|
43
|
+
body: Comment body text
|
|
44
|
+
author_type: Type of author (human, agent, system)
|
|
45
|
+
hunk_header: Optional diff hunk header for anchor computation
|
|
46
|
+
line_content: Optional line content for anchor computation
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
The created ReviewComment instance
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ResourceNotFoundError: If revision not found
|
|
53
|
+
ConflictError: If revision is superseded
|
|
54
|
+
"""
|
|
55
|
+
# Verify revision exists
|
|
56
|
+
result = await self.db.execute(
|
|
57
|
+
select(Revision).where(Revision.id == revision_id)
|
|
58
|
+
)
|
|
59
|
+
revision = result.scalar_one_or_none()
|
|
60
|
+
if revision is None:
|
|
61
|
+
raise ResourceNotFoundError("Revision", revision_id)
|
|
62
|
+
|
|
63
|
+
# Block comments on superseded revisions
|
|
64
|
+
if revision.status == RevisionStatus.SUPERSEDED.value:
|
|
65
|
+
from app.exceptions import ConflictError
|
|
66
|
+
|
|
67
|
+
raise ConflictError(
|
|
68
|
+
"Revision is superseded. Please review the latest revision."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Compute anchor - use provided values or fallback
|
|
72
|
+
anchor = compute_anchor(
|
|
73
|
+
file_path=file_path,
|
|
74
|
+
hunk_header=hunk_header or "",
|
|
75
|
+
line_content=line_content or f"line:{line_number}",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
comment = ReviewComment(
|
|
79
|
+
revision_id=revision_id,
|
|
80
|
+
file_path=file_path,
|
|
81
|
+
line_number=line_number,
|
|
82
|
+
anchor=anchor,
|
|
83
|
+
line_content=line_content,
|
|
84
|
+
body=body,
|
|
85
|
+
author_type=author_type.value,
|
|
86
|
+
resolved=False,
|
|
87
|
+
)
|
|
88
|
+
self.db.add(comment)
|
|
89
|
+
await self.db.flush()
|
|
90
|
+
await self.db.refresh(comment)
|
|
91
|
+
|
|
92
|
+
logger.info(
|
|
93
|
+
f"Added comment {comment.id} on revision {revision_id} at {file_path}:{line_number}"
|
|
94
|
+
)
|
|
95
|
+
return comment
|
|
96
|
+
|
|
97
|
+
async def get_comments_for_revision(
|
|
98
|
+
self, revision_id: str, include_resolved: bool = True
|
|
99
|
+
) -> list[ReviewComment]:
|
|
100
|
+
"""Get all comments for a revision.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
revision_id: The UUID of the revision
|
|
104
|
+
include_resolved: Whether to include resolved comments
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
List of ReviewComment instances ordered by creation time
|
|
108
|
+
"""
|
|
109
|
+
# Verify revision exists
|
|
110
|
+
result = await self.db.execute(
|
|
111
|
+
select(Revision).where(Revision.id == revision_id)
|
|
112
|
+
)
|
|
113
|
+
if result.scalar_one_or_none() is None:
|
|
114
|
+
raise ResourceNotFoundError("Revision", revision_id)
|
|
115
|
+
|
|
116
|
+
query = select(ReviewComment).where(ReviewComment.revision_id == revision_id)
|
|
117
|
+
|
|
118
|
+
if not include_resolved:
|
|
119
|
+
query = query.where(ReviewComment.resolved == False) # noqa: E712
|
|
120
|
+
|
|
121
|
+
query = query.order_by(ReviewComment.created_at.asc())
|
|
122
|
+
|
|
123
|
+
result = await self.db.execute(query)
|
|
124
|
+
return list(result.scalars().all())
|
|
125
|
+
|
|
126
|
+
async def get_comment_by_id(self, comment_id: str) -> ReviewComment:
|
|
127
|
+
"""Get a comment by its ID.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
comment_id: The UUID of the comment
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
The ReviewComment instance
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
ResourceNotFoundError: If the comment is not found
|
|
137
|
+
"""
|
|
138
|
+
result = await self.db.execute(
|
|
139
|
+
select(ReviewComment).where(ReviewComment.id == comment_id)
|
|
140
|
+
)
|
|
141
|
+
comment = result.scalar_one_or_none()
|
|
142
|
+
if comment is None:
|
|
143
|
+
raise ResourceNotFoundError("ReviewComment", comment_id)
|
|
144
|
+
return comment
|
|
145
|
+
|
|
146
|
+
async def resolve_comment(self, comment_id: str) -> ReviewComment:
|
|
147
|
+
"""Mark a comment as resolved.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
comment_id: The UUID of the comment
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
The updated ReviewComment instance
|
|
154
|
+
"""
|
|
155
|
+
comment = await self.get_comment_by_id(comment_id)
|
|
156
|
+
comment.resolved = True
|
|
157
|
+
await self.db.flush()
|
|
158
|
+
await self.db.refresh(comment)
|
|
159
|
+
logger.info(f"Resolved comment {comment_id}")
|
|
160
|
+
return comment
|
|
161
|
+
|
|
162
|
+
async def unresolve_comment(self, comment_id: str) -> ReviewComment:
|
|
163
|
+
"""Mark a comment as unresolved.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
comment_id: The UUID of the comment
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
The updated ReviewComment instance
|
|
170
|
+
"""
|
|
171
|
+
comment = await self.get_comment_by_id(comment_id)
|
|
172
|
+
comment.resolved = False
|
|
173
|
+
await self.db.flush()
|
|
174
|
+
await self.db.refresh(comment)
|
|
175
|
+
logger.info(f"Unresolved comment {comment_id}")
|
|
176
|
+
return comment
|
|
177
|
+
|
|
178
|
+
async def get_unresolved_count(self, revision_id: str) -> int:
|
|
179
|
+
"""Get the count of unresolved comments for a revision.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
revision_id: The UUID of the revision
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Number of unresolved comments
|
|
186
|
+
"""
|
|
187
|
+
result = await self.db.execute(
|
|
188
|
+
select(ReviewComment).where(
|
|
189
|
+
ReviewComment.revision_id == revision_id,
|
|
190
|
+
ReviewComment.resolved == False, # noqa: E712
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
return len(list(result.scalars().all()))
|
|
194
|
+
|
|
195
|
+
# ==================== Review Summary Operations ====================
|
|
196
|
+
|
|
197
|
+
async def submit_review(
|
|
198
|
+
self,
|
|
199
|
+
revision_id: str,
|
|
200
|
+
decision: ReviewDecision,
|
|
201
|
+
summary: str,
|
|
202
|
+
) -> ReviewSummary:
|
|
203
|
+
"""Submit a review decision for a revision.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
revision_id: The UUID of the revision
|
|
207
|
+
decision: The review decision (approved or changes_requested)
|
|
208
|
+
summary: High-level review feedback
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
The created ReviewSummary instance
|
|
212
|
+
|
|
213
|
+
Raises:
|
|
214
|
+
ValidationError: If unresolved comments exist when approving
|
|
215
|
+
ConflictError: If revision is superseded
|
|
216
|
+
"""
|
|
217
|
+
# Get revision with comments
|
|
218
|
+
result = await self.db.execute(
|
|
219
|
+
select(Revision)
|
|
220
|
+
.where(Revision.id == revision_id)
|
|
221
|
+
.options(
|
|
222
|
+
selectinload(Revision.comments),
|
|
223
|
+
selectinload(Revision.review_summary),
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
revision = result.scalar_one_or_none()
|
|
227
|
+
if revision is None:
|
|
228
|
+
raise ResourceNotFoundError("Revision", revision_id)
|
|
229
|
+
|
|
230
|
+
# Block reviews on superseded revisions
|
|
231
|
+
if revision.status == RevisionStatus.SUPERSEDED.value:
|
|
232
|
+
from app.exceptions import ConflictError
|
|
233
|
+
|
|
234
|
+
raise ConflictError(
|
|
235
|
+
"Revision is superseded. Please review the latest revision."
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Check if review already exists
|
|
239
|
+
if revision.review_summary:
|
|
240
|
+
raise ValidationError("This revision already has a review submitted")
|
|
241
|
+
|
|
242
|
+
# Note: We allow approval even with unresolved comments.
|
|
243
|
+
# Comments are informational notes; approving accepts the changes.
|
|
244
|
+
# Requesting changes sends all unresolved comments to the agent as feedback.
|
|
245
|
+
|
|
246
|
+
# Create the review summary
|
|
247
|
+
review_summary = ReviewSummary(
|
|
248
|
+
revision_id=revision_id,
|
|
249
|
+
decision=decision.value,
|
|
250
|
+
body=summary,
|
|
251
|
+
)
|
|
252
|
+
self.db.add(review_summary)
|
|
253
|
+
|
|
254
|
+
# Update revision status based on decision
|
|
255
|
+
if decision == ReviewDecision.APPROVED:
|
|
256
|
+
revision.status = RevisionStatus.APPROVED.value
|
|
257
|
+
else:
|
|
258
|
+
revision.status = RevisionStatus.CHANGES_REQUESTED.value
|
|
259
|
+
|
|
260
|
+
await self.db.flush()
|
|
261
|
+
await self.db.refresh(review_summary)
|
|
262
|
+
|
|
263
|
+
logger.info(f"Submitted review for revision {revision_id}: {decision.value}")
|
|
264
|
+
return review_summary
|
|
265
|
+
|
|
266
|
+
async def get_review_summary(self, revision_id: str) -> ReviewSummary | None:
|
|
267
|
+
"""Get the review summary for a revision.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
revision_id: The UUID of the revision
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
The ReviewSummary instance or None if no review exists
|
|
274
|
+
"""
|
|
275
|
+
result = await self.db.execute(
|
|
276
|
+
select(ReviewSummary).where(ReviewSummary.revision_id == revision_id)
|
|
277
|
+
)
|
|
278
|
+
return result.scalar_one_or_none()
|
|
279
|
+
|
|
280
|
+
# ==================== Feedback Bundle ====================
|
|
281
|
+
|
|
282
|
+
async def get_feedback_bundle(self, revision_id: str) -> FeedbackBundle:
|
|
283
|
+
"""Get the feedback bundle for a revision.
|
|
284
|
+
|
|
285
|
+
This is the structured feedback that gets injected into the agent prompt
|
|
286
|
+
when creating a new revision after changes are requested.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
revision_id: The UUID of the revision
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
FeedbackBundle containing all review feedback
|
|
293
|
+
"""
|
|
294
|
+
# Get revision with all related data
|
|
295
|
+
result = await self.db.execute(
|
|
296
|
+
select(Revision)
|
|
297
|
+
.where(Revision.id == revision_id)
|
|
298
|
+
.options(
|
|
299
|
+
selectinload(Revision.comments),
|
|
300
|
+
selectinload(Revision.review_summary),
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
revision = result.scalar_one_or_none()
|
|
304
|
+
if revision is None:
|
|
305
|
+
raise ResourceNotFoundError("Revision", revision_id)
|
|
306
|
+
|
|
307
|
+
# Build feedback comments (only unresolved ones are actionable)
|
|
308
|
+
feedback_comments = [
|
|
309
|
+
FeedbackComment(
|
|
310
|
+
file_path=comment.file_path,
|
|
311
|
+
line_number=comment.line_number,
|
|
312
|
+
anchor=comment.anchor,
|
|
313
|
+
body=comment.body,
|
|
314
|
+
line_content=comment.line_content,
|
|
315
|
+
)
|
|
316
|
+
for comment in revision.comments
|
|
317
|
+
if not comment.resolved
|
|
318
|
+
]
|
|
319
|
+
|
|
320
|
+
# Get review summary
|
|
321
|
+
summary_text = ""
|
|
322
|
+
decision_text = "pending"
|
|
323
|
+
if revision.review_summary:
|
|
324
|
+
summary_text = revision.review_summary.body
|
|
325
|
+
decision_text = revision.review_summary.decision
|
|
326
|
+
|
|
327
|
+
return FeedbackBundle(
|
|
328
|
+
ticket_id=revision.ticket_id,
|
|
329
|
+
revision_id=revision_id,
|
|
330
|
+
revision_number=revision.number,
|
|
331
|
+
decision=decision_text,
|
|
332
|
+
summary=summary_text,
|
|
333
|
+
comments=feedback_comments,
|
|
334
|
+
)
|