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,382 @@
|
|
|
1
|
+
"""Pull Request router for GitHub integration."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
from sqlalchemy import select
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
|
|
11
|
+
from app.database import get_db
|
|
12
|
+
from app.exceptions import ConfigurationError
|
|
13
|
+
from app.models.ticket import Ticket
|
|
14
|
+
from app.models.ticket_event import TicketEvent
|
|
15
|
+
from app.models.workspace import Workspace
|
|
16
|
+
from app.services.git_host import get_git_host_provider
|
|
17
|
+
from app.state_machine import ActorType, EventType, TicketState, validate_transition
|
|
18
|
+
|
|
19
|
+
router = APIRouter(prefix="/pull-requests", tags=["pull-requests"])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CreatePRRequest(BaseModel):
|
|
23
|
+
"""Request to create a GitHub Pull Request."""
|
|
24
|
+
|
|
25
|
+
ticket_id: str
|
|
26
|
+
title: str | None = None
|
|
27
|
+
body: str | None = None
|
|
28
|
+
base_branch: str = "main"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PRStatusResponse(BaseModel):
|
|
32
|
+
"""Response with PR status information."""
|
|
33
|
+
|
|
34
|
+
pr_number: int
|
|
35
|
+
pr_url: str
|
|
36
|
+
pr_state: str
|
|
37
|
+
pr_created_at: datetime | None
|
|
38
|
+
pr_merged_at: datetime | None
|
|
39
|
+
pr_head_branch: str | None
|
|
40
|
+
pr_base_branch: str | None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AddPRCommentRequest(BaseModel):
|
|
44
|
+
"""Request to add a comment to a PR."""
|
|
45
|
+
|
|
46
|
+
body: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PRCommentResponse(BaseModel):
|
|
50
|
+
"""A single PR comment."""
|
|
51
|
+
|
|
52
|
+
author: str
|
|
53
|
+
body: str
|
|
54
|
+
created_at: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class MergePRRequest(BaseModel):
|
|
58
|
+
"""Request to merge a PR."""
|
|
59
|
+
|
|
60
|
+
strategy: str = "squash" # squash, merge, rebase
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@router.post("", response_model=PRStatusResponse)
|
|
64
|
+
async def create_pull_request(
|
|
65
|
+
request: CreatePRRequest,
|
|
66
|
+
db: AsyncSession = Depends(get_db),
|
|
67
|
+
):
|
|
68
|
+
"""
|
|
69
|
+
Create a GitHub Pull Request for a ticket.
|
|
70
|
+
|
|
71
|
+
This will:
|
|
72
|
+
1. Get the ticket and its workspace
|
|
73
|
+
2. Push the workspace branch to remote
|
|
74
|
+
3. Create a PR using GitHub CLI
|
|
75
|
+
4. Update ticket with PR information
|
|
76
|
+
5. Transition ticket to REVIEW state
|
|
77
|
+
"""
|
|
78
|
+
# Get ticket
|
|
79
|
+
result = await db.execute(select(Ticket).where(Ticket.id == request.ticket_id))
|
|
80
|
+
ticket = result.scalar_one_or_none()
|
|
81
|
+
|
|
82
|
+
if not ticket:
|
|
83
|
+
raise HTTPException(
|
|
84
|
+
status_code=404, detail=f"Ticket {request.ticket_id} not found"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Check if PR already exists
|
|
88
|
+
if ticket.pr_number:
|
|
89
|
+
raise HTTPException(
|
|
90
|
+
status_code=400,
|
|
91
|
+
detail=f"Ticket already has PR #{ticket.pr_number}: {ticket.pr_url}",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Get workspace
|
|
95
|
+
result = await db.execute(
|
|
96
|
+
select(Workspace).where(Workspace.ticket_id == request.ticket_id)
|
|
97
|
+
)
|
|
98
|
+
workspace = result.scalar_one_or_none()
|
|
99
|
+
|
|
100
|
+
if not workspace:
|
|
101
|
+
raise HTTPException(
|
|
102
|
+
status_code=400,
|
|
103
|
+
detail=f"Ticket {request.ticket_id} has no workspace. Cannot create PR.",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if not workspace.worktree_path:
|
|
107
|
+
raise HTTPException(
|
|
108
|
+
status_code=400,
|
|
109
|
+
detail=f"Workspace for ticket {request.ticket_id} has no worktree path.",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
repo_path = Path(workspace.worktree_path)
|
|
113
|
+
|
|
114
|
+
if not repo_path.exists():
|
|
115
|
+
raise HTTPException(
|
|
116
|
+
status_code=400,
|
|
117
|
+
detail=f"Workspace path does not exist: {workspace.worktree_path}",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Determine branch name
|
|
121
|
+
head_branch = workspace.branch_name or f"ticket-{ticket.id[:8]}"
|
|
122
|
+
|
|
123
|
+
# Use provided title/body or generate defaults
|
|
124
|
+
pr_title = request.title or ticket.title
|
|
125
|
+
pr_body = request.body or (
|
|
126
|
+
f"Implements: {ticket.title}\n\n"
|
|
127
|
+
f"{ticket.description or ''}\n\n"
|
|
128
|
+
f"Ticket ID: {ticket.id}"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Get git host provider (auto-detects GitHub vs GitLab)
|
|
132
|
+
git_host = get_git_host_provider(repo_path)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
# Check if authenticated
|
|
136
|
+
await git_host.ensure_authenticated()
|
|
137
|
+
|
|
138
|
+
# Create PR/MR
|
|
139
|
+
pr = await git_host.create_pr(
|
|
140
|
+
repo_path=repo_path,
|
|
141
|
+
title=pr_title,
|
|
142
|
+
body=pr_body,
|
|
143
|
+
head_branch=head_branch,
|
|
144
|
+
base_branch=request.base_branch,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Update ticket with PR information
|
|
148
|
+
ticket.pr_number = pr.number
|
|
149
|
+
ticket.pr_url = pr.url
|
|
150
|
+
ticket.pr_state = pr.state
|
|
151
|
+
ticket.pr_created_at = datetime.now()
|
|
152
|
+
ticket.pr_head_branch = pr.head_branch
|
|
153
|
+
ticket.pr_base_branch = pr.base_branch
|
|
154
|
+
|
|
155
|
+
await db.commit()
|
|
156
|
+
await db.refresh(ticket)
|
|
157
|
+
|
|
158
|
+
return PRStatusResponse(
|
|
159
|
+
pr_number=ticket.pr_number,
|
|
160
|
+
pr_url=ticket.pr_url,
|
|
161
|
+
pr_state=ticket.pr_state,
|
|
162
|
+
pr_created_at=ticket.pr_created_at,
|
|
163
|
+
pr_merged_at=ticket.pr_merged_at,
|
|
164
|
+
pr_head_branch=ticket.pr_head_branch,
|
|
165
|
+
pr_base_branch=ticket.pr_base_branch,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
except ConfigurationError as e:
|
|
169
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
170
|
+
except RuntimeError as e:
|
|
171
|
+
raise HTTPException(status_code=500, detail=f"Failed to create PR: {str(e)}")
|
|
172
|
+
except Exception as e:
|
|
173
|
+
raise HTTPException(status_code=500, detail=f"Unexpected error: {str(e)}")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@router.get("/{ticket_id}", response_model=PRStatusResponse)
|
|
177
|
+
async def get_pr_status(
|
|
178
|
+
ticket_id: str,
|
|
179
|
+
db: AsyncSession = Depends(get_db),
|
|
180
|
+
):
|
|
181
|
+
"""Get the PR status for a ticket."""
|
|
182
|
+
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
|
|
183
|
+
ticket = result.scalar_one_or_none()
|
|
184
|
+
|
|
185
|
+
if not ticket:
|
|
186
|
+
raise HTTPException(status_code=404, detail=f"Ticket {ticket_id} not found")
|
|
187
|
+
|
|
188
|
+
if not ticket.pr_number:
|
|
189
|
+
raise HTTPException(
|
|
190
|
+
status_code=404, detail=f"Ticket {ticket_id} has no associated PR"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return PRStatusResponse(
|
|
194
|
+
pr_number=ticket.pr_number,
|
|
195
|
+
pr_url=ticket.pr_url,
|
|
196
|
+
pr_state=ticket.pr_state,
|
|
197
|
+
pr_created_at=ticket.pr_created_at,
|
|
198
|
+
pr_merged_at=ticket.pr_merged_at,
|
|
199
|
+
pr_head_branch=ticket.pr_head_branch,
|
|
200
|
+
pr_base_branch=ticket.pr_base_branch,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@router.post("/{ticket_id}/refresh", response_model=PRStatusResponse)
|
|
205
|
+
async def refresh_pr_status(
|
|
206
|
+
ticket_id: str,
|
|
207
|
+
db: AsyncSession = Depends(get_db),
|
|
208
|
+
):
|
|
209
|
+
"""
|
|
210
|
+
Manually refresh the PR status from GitHub.
|
|
211
|
+
|
|
212
|
+
This will fetch the latest PR state from GitHub and update the ticket.
|
|
213
|
+
"""
|
|
214
|
+
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
|
|
215
|
+
ticket = result.scalar_one_or_none()
|
|
216
|
+
|
|
217
|
+
if not ticket:
|
|
218
|
+
raise HTTPException(status_code=404, detail=f"Ticket {ticket_id} not found")
|
|
219
|
+
|
|
220
|
+
if not ticket.pr_number:
|
|
221
|
+
raise HTTPException(
|
|
222
|
+
status_code=404, detail=f"Ticket {ticket_id} has no associated PR"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# Get workspace for repo path
|
|
226
|
+
result = await db.execute(select(Workspace).where(Workspace.ticket_id == ticket_id))
|
|
227
|
+
workspace = result.scalar_one_or_none()
|
|
228
|
+
|
|
229
|
+
if not workspace or not workspace.worktree_path:
|
|
230
|
+
raise HTTPException(
|
|
231
|
+
status_code=400,
|
|
232
|
+
detail="Cannot refresh PR status: workspace not found or no worktree path",
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
repo_path = Path(workspace.worktree_path)
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
git_host = get_git_host_provider(repo_path)
|
|
239
|
+
pr_details = await git_host.get_pr_details(repo_path, ticket.pr_number)
|
|
240
|
+
|
|
241
|
+
# Update ticket
|
|
242
|
+
old_state = ticket.pr_state
|
|
243
|
+
ticket.pr_state = pr_details["state"]
|
|
244
|
+
|
|
245
|
+
if pr_details.get("merged") and not ticket.pr_merged_at:
|
|
246
|
+
ticket.pr_merged_at = datetime.now()
|
|
247
|
+
|
|
248
|
+
# Auto-transition ticket if PR was merged
|
|
249
|
+
if pr_details.get("merged") and old_state != "MERGED":
|
|
250
|
+
ticket.pr_state = "MERGED"
|
|
251
|
+
current_state = TicketState(ticket.state)
|
|
252
|
+
if validate_transition(current_state, TicketState.DONE):
|
|
253
|
+
ticket.state = TicketState.DONE.value
|
|
254
|
+
event = TicketEvent(
|
|
255
|
+
ticket_id=ticket.id,
|
|
256
|
+
event_type=EventType.TRANSITIONED.value,
|
|
257
|
+
from_state=current_state.value,
|
|
258
|
+
to_state=TicketState.DONE.value,
|
|
259
|
+
actor_type=ActorType.SYSTEM.value,
|
|
260
|
+
actor_id="pr_refresh",
|
|
261
|
+
reason="PR merged on remote",
|
|
262
|
+
)
|
|
263
|
+
db.add(event)
|
|
264
|
+
|
|
265
|
+
await db.commit()
|
|
266
|
+
await db.refresh(ticket)
|
|
267
|
+
|
|
268
|
+
return PRStatusResponse(
|
|
269
|
+
pr_number=ticket.pr_number,
|
|
270
|
+
pr_url=ticket.pr_url,
|
|
271
|
+
pr_state=ticket.pr_state,
|
|
272
|
+
pr_created_at=ticket.pr_created_at,
|
|
273
|
+
pr_merged_at=ticket.pr_merged_at,
|
|
274
|
+
pr_head_branch=ticket.pr_head_branch,
|
|
275
|
+
pr_base_branch=ticket.pr_base_branch,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
except Exception as e:
|
|
279
|
+
raise HTTPException(
|
|
280
|
+
status_code=500, detail=f"Failed to refresh PR status: {str(e)}"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ===================== PR Comment Endpoints =====================
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
async def _get_ticket_with_pr(ticket_id: str, db: AsyncSession) -> tuple:
|
|
288
|
+
"""Get ticket with PR info and workspace repo path."""
|
|
289
|
+
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
|
|
290
|
+
ticket = result.scalar_one_or_none()
|
|
291
|
+
if not ticket:
|
|
292
|
+
raise HTTPException(status_code=404, detail=f"Ticket {ticket_id} not found")
|
|
293
|
+
if not ticket.pr_number:
|
|
294
|
+
raise HTTPException(status_code=400, detail="Ticket has no associated PR")
|
|
295
|
+
|
|
296
|
+
result = await db.execute(select(Workspace).where(Workspace.ticket_id == ticket_id))
|
|
297
|
+
workspace = result.scalar_one_or_none()
|
|
298
|
+
if not workspace or not workspace.worktree_path:
|
|
299
|
+
raise HTTPException(status_code=400, detail="No workspace found for ticket")
|
|
300
|
+
|
|
301
|
+
repo_path = Path(workspace.worktree_path)
|
|
302
|
+
return ticket, repo_path
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@router.post("/{ticket_id}/comments", response_model=dict)
|
|
306
|
+
async def add_pr_comment(
|
|
307
|
+
ticket_id: str,
|
|
308
|
+
request: AddPRCommentRequest,
|
|
309
|
+
db: AsyncSession = Depends(get_db),
|
|
310
|
+
):
|
|
311
|
+
"""Add a comment to a ticket's PR."""
|
|
312
|
+
ticket, repo_path = await _get_ticket_with_pr(ticket_id, db)
|
|
313
|
+
git_host = get_git_host_provider(repo_path)
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
result = await git_host.add_pr_comment(
|
|
317
|
+
repo_path, ticket.pr_number, request.body
|
|
318
|
+
)
|
|
319
|
+
return result
|
|
320
|
+
except Exception as e:
|
|
321
|
+
raise HTTPException(status_code=500, detail=f"Failed to add comment: {str(e)}")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@router.get("/{ticket_id}/comments", response_model=list[PRCommentResponse])
|
|
325
|
+
async def list_pr_comments(
|
|
326
|
+
ticket_id: str,
|
|
327
|
+
db: AsyncSession = Depends(get_db),
|
|
328
|
+
):
|
|
329
|
+
"""List all comments on a ticket's PR."""
|
|
330
|
+
ticket, repo_path = await _get_ticket_with_pr(ticket_id, db)
|
|
331
|
+
git_host = get_git_host_provider(repo_path)
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
comments = await git_host.list_pr_comments(repo_path, ticket.pr_number)
|
|
335
|
+
return [
|
|
336
|
+
PRCommentResponse(
|
|
337
|
+
author=c.get("author", {}).get("login", "unknown"),
|
|
338
|
+
body=c.get("body", ""),
|
|
339
|
+
created_at=c.get("createdAt", ""),
|
|
340
|
+
)
|
|
341
|
+
for c in comments
|
|
342
|
+
]
|
|
343
|
+
except Exception as e:
|
|
344
|
+
raise HTTPException(
|
|
345
|
+
status_code=500, detail=f"Failed to list comments: {str(e)}"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@router.post("/{ticket_id}/merge", response_model=dict)
|
|
350
|
+
async def merge_pr_endpoint(
|
|
351
|
+
ticket_id: str,
|
|
352
|
+
request: MergePRRequest,
|
|
353
|
+
db: AsyncSession = Depends(get_db),
|
|
354
|
+
):
|
|
355
|
+
"""Merge a ticket's PR on GitHub with the given strategy."""
|
|
356
|
+
ticket, repo_path = await _get_ticket_with_pr(ticket_id, db)
|
|
357
|
+
git_host = get_git_host_provider(repo_path)
|
|
358
|
+
|
|
359
|
+
try:
|
|
360
|
+
result = await git_host.merge_pr(repo_path, ticket.pr_number, request.strategy)
|
|
361
|
+
|
|
362
|
+
# Update ticket state on successful merge
|
|
363
|
+
ticket.pr_state = "MERGED"
|
|
364
|
+
ticket.pr_merged_at = datetime.now()
|
|
365
|
+
current_state = TicketState(ticket.state)
|
|
366
|
+
if validate_transition(current_state, TicketState.DONE):
|
|
367
|
+
ticket.state = TicketState.DONE.value
|
|
368
|
+
event = TicketEvent(
|
|
369
|
+
ticket_id=ticket.id,
|
|
370
|
+
event_type=EventType.TRANSITIONED.value,
|
|
371
|
+
from_state=current_state.value,
|
|
372
|
+
to_state=TicketState.DONE.value,
|
|
373
|
+
actor_type=ActorType.SYSTEM.value,
|
|
374
|
+
actor_id="pr_merge",
|
|
375
|
+
reason="PR merged",
|
|
376
|
+
)
|
|
377
|
+
db.add(event)
|
|
378
|
+
await db.commit()
|
|
379
|
+
|
|
380
|
+
return result
|
|
381
|
+
except Exception as e:
|
|
382
|
+
raise HTTPException(status_code=500, detail=f"Failed to merge PR: {str(e)}")
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""API router for Repository endpoints."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
|
|
6
|
+
from app.database import get_db
|
|
7
|
+
from app.schemas.repo import (
|
|
8
|
+
DiscoveredRepoResponse,
|
|
9
|
+
DiscoverReposRequest,
|
|
10
|
+
DiscoverReposResponse,
|
|
11
|
+
RepoCreate,
|
|
12
|
+
RepoListResponse,
|
|
13
|
+
RepoResponse,
|
|
14
|
+
RepoUpdate,
|
|
15
|
+
ValidateRepoRequest,
|
|
16
|
+
ValidateRepoResponse,
|
|
17
|
+
)
|
|
18
|
+
from app.services.repo_discovery_service import RepoDiscoveryService
|
|
19
|
+
|
|
20
|
+
router = APIRouter(prefix="/repos", tags=["repos"])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ============================================================================
|
|
24
|
+
# Repo CRUD endpoints
|
|
25
|
+
# ============================================================================
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@router.post(
|
|
29
|
+
"",
|
|
30
|
+
response_model=RepoResponse,
|
|
31
|
+
status_code=status.HTTP_201_CREATED,
|
|
32
|
+
summary="Register a new repository",
|
|
33
|
+
)
|
|
34
|
+
async def create_repo(
|
|
35
|
+
data: RepoCreate,
|
|
36
|
+
db: AsyncSession = Depends(get_db),
|
|
37
|
+
) -> RepoResponse:
|
|
38
|
+
"""
|
|
39
|
+
Register a new git repository in the global registry.
|
|
40
|
+
|
|
41
|
+
**Validation:**
|
|
42
|
+
- Path must exist and be a directory
|
|
43
|
+
- Path must be a valid git repository
|
|
44
|
+
- Path must not already be registered
|
|
45
|
+
|
|
46
|
+
**Metadata:**
|
|
47
|
+
- Repository name derived from path if not provided
|
|
48
|
+
- Git metadata (default branch, remote URL) auto-detected
|
|
49
|
+
"""
|
|
50
|
+
service = RepoDiscoveryService(db)
|
|
51
|
+
try:
|
|
52
|
+
repo = await service.register_repo(
|
|
53
|
+
path=data.path,
|
|
54
|
+
display_name=data.display_name,
|
|
55
|
+
setup_script=data.setup_script,
|
|
56
|
+
cleanup_script=data.cleanup_script,
|
|
57
|
+
dev_server_script=data.dev_server_script,
|
|
58
|
+
)
|
|
59
|
+
return RepoResponse.model_validate(repo)
|
|
60
|
+
except ValueError as e:
|
|
61
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@router.get(
|
|
65
|
+
"",
|
|
66
|
+
response_model=RepoListResponse,
|
|
67
|
+
summary="List all registered repositories",
|
|
68
|
+
)
|
|
69
|
+
async def list_repos(
|
|
70
|
+
db: AsyncSession = Depends(get_db),
|
|
71
|
+
) -> RepoListResponse:
|
|
72
|
+
"""Get all registered repositories, ordered by creation date (newest first)."""
|
|
73
|
+
service = RepoDiscoveryService(db)
|
|
74
|
+
repos = await service.get_all_repos()
|
|
75
|
+
return RepoListResponse(
|
|
76
|
+
repos=[RepoResponse.model_validate(r) for r in repos],
|
|
77
|
+
total=len(repos),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@router.get(
|
|
82
|
+
"/{repo_id}",
|
|
83
|
+
response_model=RepoResponse,
|
|
84
|
+
summary="Get a repository by ID",
|
|
85
|
+
)
|
|
86
|
+
async def get_repo(
|
|
87
|
+
repo_id: str,
|
|
88
|
+
db: AsyncSession = Depends(get_db),
|
|
89
|
+
) -> RepoResponse:
|
|
90
|
+
"""Get a repository by its ID."""
|
|
91
|
+
service = RepoDiscoveryService(db)
|
|
92
|
+
repo = await service.get_repo_by_id(repo_id)
|
|
93
|
+
if not repo:
|
|
94
|
+
raise HTTPException(status_code=404, detail=f"Repo not found: {repo_id}")
|
|
95
|
+
return RepoResponse.model_validate(repo)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@router.patch(
|
|
99
|
+
"/{repo_id}",
|
|
100
|
+
response_model=RepoResponse,
|
|
101
|
+
summary="Update a repository",
|
|
102
|
+
)
|
|
103
|
+
async def update_repo(
|
|
104
|
+
repo_id: str,
|
|
105
|
+
data: RepoUpdate,
|
|
106
|
+
db: AsyncSession = Depends(get_db),
|
|
107
|
+
) -> RepoResponse:
|
|
108
|
+
"""
|
|
109
|
+
Update a repository's configuration.
|
|
110
|
+
|
|
111
|
+
**Updatable fields:**
|
|
112
|
+
- display_name - User-friendly name
|
|
113
|
+
- setup_script - Optional setup script
|
|
114
|
+
- cleanup_script - Optional cleanup script
|
|
115
|
+
- dev_server_script - Optional dev server script
|
|
116
|
+
|
|
117
|
+
**Note:** Path and git metadata are read-only.
|
|
118
|
+
"""
|
|
119
|
+
service = RepoDiscoveryService(db)
|
|
120
|
+
try:
|
|
121
|
+
repo = await service.update_repo(
|
|
122
|
+
repo_id=repo_id,
|
|
123
|
+
display_name=data.display_name,
|
|
124
|
+
setup_script=data.setup_script,
|
|
125
|
+
cleanup_script=data.cleanup_script,
|
|
126
|
+
dev_server_script=data.dev_server_script,
|
|
127
|
+
)
|
|
128
|
+
return RepoResponse.model_validate(repo)
|
|
129
|
+
except ValueError as e:
|
|
130
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@router.delete(
|
|
134
|
+
"/{repo_id}",
|
|
135
|
+
status_code=status.HTTP_204_NO_CONTENT,
|
|
136
|
+
summary="Delete a repository",
|
|
137
|
+
)
|
|
138
|
+
async def delete_repo(
|
|
139
|
+
repo_id: str,
|
|
140
|
+
db: AsyncSession = Depends(get_db),
|
|
141
|
+
) -> None:
|
|
142
|
+
"""
|
|
143
|
+
Delete a repository from the global registry.
|
|
144
|
+
|
|
145
|
+
**Warning:** This will cascade delete all BoardRepo associations.
|
|
146
|
+
Boards using this repo will no longer have it available.
|
|
147
|
+
"""
|
|
148
|
+
service = RepoDiscoveryService(db)
|
|
149
|
+
try:
|
|
150
|
+
await service.delete_repo(repo_id)
|
|
151
|
+
except ValueError as e:
|
|
152
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ============================================================================
|
|
156
|
+
# Discovery endpoints
|
|
157
|
+
# ============================================================================
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@router.post(
|
|
161
|
+
"/discover",
|
|
162
|
+
response_model=DiscoverReposResponse,
|
|
163
|
+
summary="Discover git repositories",
|
|
164
|
+
)
|
|
165
|
+
async def discover_repos(
|
|
166
|
+
request: DiscoverReposRequest,
|
|
167
|
+
db: AsyncSession = Depends(get_db),
|
|
168
|
+
) -> DiscoverReposResponse:
|
|
169
|
+
"""
|
|
170
|
+
Scan directories for git repositories.
|
|
171
|
+
|
|
172
|
+
**How it works:**
|
|
173
|
+
1. Walks directory tree up to `max_depth` levels
|
|
174
|
+
2. Excludes common non-repo directories (node_modules, venv, etc.)
|
|
175
|
+
3. Detects .git directories
|
|
176
|
+
4. Extracts git metadata (branch, remote)
|
|
177
|
+
5. Returns list of discovered repos (not yet registered)
|
|
178
|
+
|
|
179
|
+
**Example:**
|
|
180
|
+
```json
|
|
181
|
+
{
|
|
182
|
+
"search_paths": ["~/code", "~/projects"],
|
|
183
|
+
"max_depth": 3,
|
|
184
|
+
"exclude_patterns": ["archive", "old"]
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**Next steps:**
|
|
189
|
+
- Review discovered repos
|
|
190
|
+
- Use POST /repos to register desired repos
|
|
191
|
+
"""
|
|
192
|
+
service = RepoDiscoveryService(db)
|
|
193
|
+
|
|
194
|
+
exclude_set = set(request.exclude_patterns) if request.exclude_patterns else None
|
|
195
|
+
|
|
196
|
+
discovered = await service.discover_repos(
|
|
197
|
+
search_paths=request.search_paths,
|
|
198
|
+
max_depth=request.max_depth,
|
|
199
|
+
exclude_patterns=exclude_set,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
return DiscoverReposResponse(
|
|
203
|
+
discovered=[
|
|
204
|
+
DiscoveredRepoResponse(
|
|
205
|
+
path=r.path,
|
|
206
|
+
name=r.name,
|
|
207
|
+
display_name=r.display_name,
|
|
208
|
+
default_branch=r.default_branch,
|
|
209
|
+
remote_url=r.remote_url,
|
|
210
|
+
is_valid=r.is_valid,
|
|
211
|
+
error_message=r.error_message,
|
|
212
|
+
)
|
|
213
|
+
for r in discovered
|
|
214
|
+
],
|
|
215
|
+
total=len(discovered),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@router.post(
|
|
220
|
+
"/validate",
|
|
221
|
+
response_model=ValidateRepoResponse,
|
|
222
|
+
summary="Validate a repository path",
|
|
223
|
+
)
|
|
224
|
+
async def validate_repo(
|
|
225
|
+
request: ValidateRepoRequest,
|
|
226
|
+
db: AsyncSession = Depends(get_db),
|
|
227
|
+
) -> ValidateRepoResponse:
|
|
228
|
+
"""
|
|
229
|
+
Validate that a path is a valid git repository.
|
|
230
|
+
|
|
231
|
+
**Checks:**
|
|
232
|
+
- Path exists
|
|
233
|
+
- Path is a directory
|
|
234
|
+
- Path contains a .git directory
|
|
235
|
+
- Git repository is accessible
|
|
236
|
+
|
|
237
|
+
**Returns:**
|
|
238
|
+
- is_valid: Whether path is a valid repo
|
|
239
|
+
- path: Normalized absolute path
|
|
240
|
+
- metadata: Git metadata if valid
|
|
241
|
+
- error_message: Error description if invalid
|
|
242
|
+
"""
|
|
243
|
+
service = RepoDiscoveryService(db)
|
|
244
|
+
validation = await service.validate_repo_path(request.path)
|
|
245
|
+
|
|
246
|
+
metadata_response = None
|
|
247
|
+
if validation.metadata:
|
|
248
|
+
metadata_response = DiscoveredRepoResponse(
|
|
249
|
+
path=validation.metadata.path,
|
|
250
|
+
name=validation.metadata.name,
|
|
251
|
+
display_name=validation.metadata.display_name,
|
|
252
|
+
default_branch=validation.metadata.default_branch,
|
|
253
|
+
remote_url=validation.metadata.remote_url,
|
|
254
|
+
is_valid=validation.metadata.is_valid,
|
|
255
|
+
error_message=validation.metadata.error_message,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
return ValidateRepoResponse(
|
|
259
|
+
is_valid=validation.is_valid,
|
|
260
|
+
path=validation.path,
|
|
261
|
+
error_message=validation.error_message,
|
|
262
|
+
metadata=metadata_response,
|
|
263
|
+
)
|