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,939 @@
|
|
|
1
|
+
"""API router for Revision and Review endpoints."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
|
|
11
|
+
from app.database import get_db
|
|
12
|
+
from app.exceptions import ConflictError, ResourceNotFoundError, ValidationError
|
|
13
|
+
from app.models.job import Job, JobKind, JobStatus
|
|
14
|
+
from app.models.review_comment import AuthorType
|
|
15
|
+
from app.models.review_summary import ReviewDecision
|
|
16
|
+
from app.models.revision import RevisionStatus
|
|
17
|
+
from app.schemas.common import PaginatedResponse
|
|
18
|
+
from app.schemas.review import (
|
|
19
|
+
FeedbackBundle,
|
|
20
|
+
ReviewCommentCreate,
|
|
21
|
+
ReviewCommentListResponse,
|
|
22
|
+
ReviewCommentResponse,
|
|
23
|
+
ReviewSubmit,
|
|
24
|
+
ReviewSummaryResponse,
|
|
25
|
+
)
|
|
26
|
+
from app.schemas.revision import (
|
|
27
|
+
DiffFile,
|
|
28
|
+
DiffPatchResponse,
|
|
29
|
+
DiffSummaryResponse,
|
|
30
|
+
RevisionDetailResponse,
|
|
31
|
+
RevisionDiffResponse,
|
|
32
|
+
RevisionListResponse,
|
|
33
|
+
RevisionResponse,
|
|
34
|
+
RevisionTimelineResponse,
|
|
35
|
+
TimelineEvent,
|
|
36
|
+
)
|
|
37
|
+
from app.services.review_service import ReviewService
|
|
38
|
+
from app.services.revision_service import RevisionService
|
|
39
|
+
from app.services.ticket_service import TicketService
|
|
40
|
+
from app.state_machine import ActorType as TicketActorType
|
|
41
|
+
from app.state_machine import TicketState
|
|
42
|
+
|
|
43
|
+
router = APIRouter(tags=["revisions"])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ==================== Revision Endpoints ====================
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@router.get(
|
|
50
|
+
"/tickets/{ticket_id}/revisions",
|
|
51
|
+
response_model=RevisionListResponse,
|
|
52
|
+
summary="Get all revisions for a ticket",
|
|
53
|
+
)
|
|
54
|
+
async def get_ticket_revisions(
|
|
55
|
+
ticket_id: str,
|
|
56
|
+
db: AsyncSession = Depends(get_db),
|
|
57
|
+
) -> RevisionListResponse:
|
|
58
|
+
"""Get all revisions for a ticket, ordered by revision number descending."""
|
|
59
|
+
service = RevisionService(db)
|
|
60
|
+
try:
|
|
61
|
+
revisions = await service.get_revisions_for_ticket(ticket_id)
|
|
62
|
+
except ResourceNotFoundError as e:
|
|
63
|
+
raise HTTPException(status_code=404, detail=e.message)
|
|
64
|
+
|
|
65
|
+
revision_responses = [
|
|
66
|
+
RevisionResponse(
|
|
67
|
+
id=r.id,
|
|
68
|
+
ticket_id=r.ticket_id,
|
|
69
|
+
job_id=r.job_id,
|
|
70
|
+
number=r.number,
|
|
71
|
+
status=RevisionStatus(r.status),
|
|
72
|
+
diff_stat_evidence_id=r.diff_stat_evidence_id,
|
|
73
|
+
diff_patch_evidence_id=r.diff_patch_evidence_id,
|
|
74
|
+
created_at=r.created_at,
|
|
75
|
+
unresolved_comment_count=r.unresolved_comment_count,
|
|
76
|
+
)
|
|
77
|
+
for r in revisions
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
return RevisionListResponse(
|
|
81
|
+
revisions=revision_responses,
|
|
82
|
+
total=len(revision_responses),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@router.get(
|
|
87
|
+
"/revisions/{revision_id}",
|
|
88
|
+
response_model=RevisionDetailResponse,
|
|
89
|
+
summary="Get a revision by ID",
|
|
90
|
+
)
|
|
91
|
+
async def get_revision(
|
|
92
|
+
revision_id: str,
|
|
93
|
+
db: AsyncSession = Depends(get_db),
|
|
94
|
+
) -> RevisionDetailResponse:
|
|
95
|
+
"""Get detailed information about a revision including diff content."""
|
|
96
|
+
service = RevisionService(db)
|
|
97
|
+
try:
|
|
98
|
+
revision = await service.get_revision_by_id(revision_id)
|
|
99
|
+
diff_stat, diff_patch = await service.get_revision_diff(revision_id)
|
|
100
|
+
except ResourceNotFoundError as e:
|
|
101
|
+
raise HTTPException(status_code=404, detail=e.message)
|
|
102
|
+
|
|
103
|
+
return RevisionDetailResponse(
|
|
104
|
+
id=revision.id,
|
|
105
|
+
ticket_id=revision.ticket_id,
|
|
106
|
+
job_id=revision.job_id,
|
|
107
|
+
number=revision.number,
|
|
108
|
+
status=RevisionStatus(revision.status),
|
|
109
|
+
diff_stat_evidence_id=revision.diff_stat_evidence_id,
|
|
110
|
+
diff_patch_evidence_id=revision.diff_patch_evidence_id,
|
|
111
|
+
created_at=revision.created_at,
|
|
112
|
+
unresolved_comment_count=revision.unresolved_comment_count,
|
|
113
|
+
diff_stat=diff_stat,
|
|
114
|
+
diff_patch=diff_patch,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@router.get(
|
|
119
|
+
"/revisions/{revision_id}/diff",
|
|
120
|
+
response_model=RevisionDiffResponse,
|
|
121
|
+
summary="Get the diff content for a revision",
|
|
122
|
+
)
|
|
123
|
+
async def get_revision_diff(
|
|
124
|
+
revision_id: str,
|
|
125
|
+
db: AsyncSession = Depends(get_db),
|
|
126
|
+
) -> RevisionDiffResponse:
|
|
127
|
+
"""Get the diff content for a revision with parsed file information."""
|
|
128
|
+
service = RevisionService(db)
|
|
129
|
+
try:
|
|
130
|
+
diff_stat, diff_patch = await service.get_revision_diff(revision_id)
|
|
131
|
+
except ResourceNotFoundError as e:
|
|
132
|
+
raise HTTPException(status_code=404, detail=e.message)
|
|
133
|
+
|
|
134
|
+
# Parse diff stat to extract file information
|
|
135
|
+
files = _parse_diff_stat(diff_stat) if diff_stat else []
|
|
136
|
+
|
|
137
|
+
return RevisionDiffResponse(
|
|
138
|
+
revision_id=revision_id,
|
|
139
|
+
diff_stat=diff_stat,
|
|
140
|
+
diff_patch=diff_patch,
|
|
141
|
+
files=files,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@router.get(
|
|
146
|
+
"/revisions/{revision_id}/diff/summary",
|
|
147
|
+
response_model=DiffSummaryResponse,
|
|
148
|
+
summary="Get lightweight diff summary (stat + file list)",
|
|
149
|
+
)
|
|
150
|
+
async def get_revision_diff_summary(
|
|
151
|
+
revision_id: str,
|
|
152
|
+
db: AsyncSession = Depends(get_db),
|
|
153
|
+
) -> DiffSummaryResponse:
|
|
154
|
+
"""Get the lightweight diff summary for initial UI load.
|
|
155
|
+
|
|
156
|
+
This returns only the diff stat and file list - no heavy patch content.
|
|
157
|
+
Use this for the file tree view. Only fetch /diff/patch when user actually
|
|
158
|
+
opens the diff viewer.
|
|
159
|
+
"""
|
|
160
|
+
service = RevisionService(db)
|
|
161
|
+
try:
|
|
162
|
+
diff_stat = await service.get_revision_diff_summary(revision_id)
|
|
163
|
+
except ResourceNotFoundError as e:
|
|
164
|
+
raise HTTPException(status_code=404, detail=e.message)
|
|
165
|
+
|
|
166
|
+
# Parse diff stat to extract file information
|
|
167
|
+
files = _parse_diff_stat(diff_stat) if diff_stat else []
|
|
168
|
+
|
|
169
|
+
return DiffSummaryResponse(
|
|
170
|
+
revision_id=revision_id,
|
|
171
|
+
diff_stat=diff_stat,
|
|
172
|
+
files=files,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@router.get(
|
|
177
|
+
"/revisions/{revision_id}/diff/patch",
|
|
178
|
+
response_model=DiffPatchResponse,
|
|
179
|
+
summary="Get heavyweight diff patch content",
|
|
180
|
+
)
|
|
181
|
+
async def get_revision_diff_patch(
|
|
182
|
+
revision_id: str,
|
|
183
|
+
db: AsyncSession = Depends(get_db),
|
|
184
|
+
) -> DiffPatchResponse:
|
|
185
|
+
"""Get the full diff patch content.
|
|
186
|
+
|
|
187
|
+
This is a heavyweight endpoint - only call when user actually opens
|
|
188
|
+
the diff viewer and wants to see the code changes.
|
|
189
|
+
"""
|
|
190
|
+
service = RevisionService(db)
|
|
191
|
+
try:
|
|
192
|
+
diff_patch = await service.get_revision_diff_patch(revision_id)
|
|
193
|
+
except ResourceNotFoundError as e:
|
|
194
|
+
raise HTTPException(status_code=404, detail=e.message)
|
|
195
|
+
|
|
196
|
+
return DiffPatchResponse(
|
|
197
|
+
revision_id=revision_id,
|
|
198
|
+
diff_patch=diff_patch,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _parse_diff_stat(diff_stat: str) -> list[DiffFile]:
|
|
203
|
+
"""Parse git diff --stat output to extract file information.
|
|
204
|
+
|
|
205
|
+
Example input:
|
|
206
|
+
backend/app/models/ticket.py | 10 +++++-----
|
|
207
|
+
backend/app/services/new.py | 50 +++++++++++++++++++++++++++++++++++
|
|
208
|
+
2 files changed, 55 insertions(+), 5 deletions(-)
|
|
209
|
+
"""
|
|
210
|
+
files = []
|
|
211
|
+
# Match lines like: path/to/file | 10 +++++-----
|
|
212
|
+
pattern = r"^\s*(.+?)\s+\|\s+(\d+)\s+(\+*)(\-*)\s*$"
|
|
213
|
+
|
|
214
|
+
for line in diff_stat.split("\n"):
|
|
215
|
+
match = re.match(pattern, line)
|
|
216
|
+
if match:
|
|
217
|
+
path = match.group(1).strip()
|
|
218
|
+
additions = len(match.group(3))
|
|
219
|
+
deletions = len(match.group(4))
|
|
220
|
+
|
|
221
|
+
# Determine status
|
|
222
|
+
if "=>" in path: # renamed
|
|
223
|
+
status = "renamed"
|
|
224
|
+
# Extract old and new paths from "old => new"
|
|
225
|
+
parts = path.split("=>")
|
|
226
|
+
if len(parts) == 2:
|
|
227
|
+
old_path = parts[0].strip().strip("{").strip()
|
|
228
|
+
new_path = parts[1].strip().strip("}").strip()
|
|
229
|
+
files.append(
|
|
230
|
+
DiffFile(
|
|
231
|
+
path=new_path,
|
|
232
|
+
old_path=old_path,
|
|
233
|
+
additions=additions,
|
|
234
|
+
deletions=deletions,
|
|
235
|
+
status=status,
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
continue
|
|
239
|
+
elif additions > 0 and deletions == 0:
|
|
240
|
+
# Could be new file, but git diff --stat doesn't clearly indicate
|
|
241
|
+
status = "modified" # Default to modified
|
|
242
|
+
elif deletions > 0 and additions == 0:
|
|
243
|
+
status = "modified"
|
|
244
|
+
else:
|
|
245
|
+
status = "modified"
|
|
246
|
+
|
|
247
|
+
files.append(
|
|
248
|
+
DiffFile(
|
|
249
|
+
path=path,
|
|
250
|
+
additions=additions,
|
|
251
|
+
deletions=deletions,
|
|
252
|
+
status=status,
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
return files
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ==================== Review Comment Endpoints ====================
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@router.post(
|
|
263
|
+
"/revisions/{revision_id}/comments",
|
|
264
|
+
response_model=ReviewCommentResponse,
|
|
265
|
+
status_code=status.HTTP_201_CREATED,
|
|
266
|
+
summary="Add an inline comment to a revision",
|
|
267
|
+
)
|
|
268
|
+
async def add_comment(
|
|
269
|
+
revision_id: str,
|
|
270
|
+
data: ReviewCommentCreate,
|
|
271
|
+
db: AsyncSession = Depends(get_db),
|
|
272
|
+
) -> ReviewCommentResponse:
|
|
273
|
+
"""Add an inline comment on a specific line in the revision diff.
|
|
274
|
+
|
|
275
|
+
Returns 409 Conflict if the revision is superseded.
|
|
276
|
+
"""
|
|
277
|
+
service = ReviewService(db)
|
|
278
|
+
try:
|
|
279
|
+
comment = await service.add_comment(
|
|
280
|
+
revision_id=revision_id,
|
|
281
|
+
file_path=data.file_path,
|
|
282
|
+
line_number=data.line_number,
|
|
283
|
+
body=data.body,
|
|
284
|
+
author_type=AuthorType(data.author_type.value),
|
|
285
|
+
hunk_header=data.hunk_header,
|
|
286
|
+
line_content=data.line_content,
|
|
287
|
+
)
|
|
288
|
+
await db.commit()
|
|
289
|
+
except ResourceNotFoundError as e:
|
|
290
|
+
raise HTTPException(status_code=404, detail=e.message)
|
|
291
|
+
except ConflictError as e:
|
|
292
|
+
raise HTTPException(status_code=409, detail=e.message)
|
|
293
|
+
|
|
294
|
+
return ReviewCommentResponse(
|
|
295
|
+
id=comment.id,
|
|
296
|
+
revision_id=comment.revision_id,
|
|
297
|
+
file_path=comment.file_path,
|
|
298
|
+
line_number=comment.line_number,
|
|
299
|
+
anchor=comment.anchor,
|
|
300
|
+
body=comment.body,
|
|
301
|
+
author_type=comment.author_type_enum,
|
|
302
|
+
resolved=comment.resolved,
|
|
303
|
+
created_at=comment.created_at,
|
|
304
|
+
line_content=comment.line_content,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@router.get(
|
|
309
|
+
"/revisions/{revision_id}/comments",
|
|
310
|
+
summary="Get all comments for a revision",
|
|
311
|
+
)
|
|
312
|
+
async def get_revision_comments(
|
|
313
|
+
revision_id: str,
|
|
314
|
+
include_resolved: bool = True,
|
|
315
|
+
page: int | None = Query(
|
|
316
|
+
None,
|
|
317
|
+
ge=1,
|
|
318
|
+
description="Page number (1-based). Omit for all results.",
|
|
319
|
+
),
|
|
320
|
+
limit: int | None = Query(
|
|
321
|
+
None,
|
|
322
|
+
ge=1,
|
|
323
|
+
le=200,
|
|
324
|
+
description="Items per page. Omit for all results.",
|
|
325
|
+
),
|
|
326
|
+
db: AsyncSession = Depends(get_db),
|
|
327
|
+
) -> ReviewCommentListResponse | PaginatedResponse[ReviewCommentResponse]:
|
|
328
|
+
"""Get all comments for a revision.
|
|
329
|
+
|
|
330
|
+
**Pagination (optional):**
|
|
331
|
+
- If `page` and `limit` are provided, returns paginated response.
|
|
332
|
+
- If omitted, returns all comments (backward compatible).
|
|
333
|
+
"""
|
|
334
|
+
service = ReviewService(db)
|
|
335
|
+
try:
|
|
336
|
+
comments = await service.get_comments_for_revision(
|
|
337
|
+
revision_id, include_resolved=include_resolved
|
|
338
|
+
)
|
|
339
|
+
unresolved_count = await service.get_unresolved_count(revision_id)
|
|
340
|
+
except ResourceNotFoundError as e:
|
|
341
|
+
raise HTTPException(status_code=404, detail=e.message)
|
|
342
|
+
|
|
343
|
+
comment_responses = [
|
|
344
|
+
ReviewCommentResponse(
|
|
345
|
+
id=c.id,
|
|
346
|
+
revision_id=c.revision_id,
|
|
347
|
+
file_path=c.file_path,
|
|
348
|
+
line_number=c.line_number,
|
|
349
|
+
anchor=c.anchor,
|
|
350
|
+
body=c.body,
|
|
351
|
+
author_type=c.author_type_enum,
|
|
352
|
+
resolved=c.resolved,
|
|
353
|
+
created_at=c.created_at,
|
|
354
|
+
)
|
|
355
|
+
for c in comments
|
|
356
|
+
]
|
|
357
|
+
|
|
358
|
+
# If pagination params are provided, return paginated response
|
|
359
|
+
if page is not None and limit is not None:
|
|
360
|
+
total = len(comment_responses)
|
|
361
|
+
offset = (page - 1) * limit
|
|
362
|
+
page_items = comment_responses[offset : offset + limit]
|
|
363
|
+
return PaginatedResponse[ReviewCommentResponse](
|
|
364
|
+
items=page_items,
|
|
365
|
+
total=total,
|
|
366
|
+
page=page,
|
|
367
|
+
limit=limit,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
# Backward compatible: return all
|
|
371
|
+
return ReviewCommentListResponse(
|
|
372
|
+
comments=comment_responses,
|
|
373
|
+
total=len(comment_responses),
|
|
374
|
+
unresolved_count=unresolved_count,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@router.post(
|
|
379
|
+
"/comments/{comment_id}/resolve",
|
|
380
|
+
response_model=ReviewCommentResponse,
|
|
381
|
+
summary="Resolve a comment",
|
|
382
|
+
)
|
|
383
|
+
async def resolve_comment(
|
|
384
|
+
comment_id: str,
|
|
385
|
+
db: AsyncSession = Depends(get_db),
|
|
386
|
+
) -> ReviewCommentResponse:
|
|
387
|
+
"""Mark a comment as resolved."""
|
|
388
|
+
service = ReviewService(db)
|
|
389
|
+
try:
|
|
390
|
+
comment = await service.resolve_comment(comment_id)
|
|
391
|
+
await db.commit()
|
|
392
|
+
except ResourceNotFoundError as e:
|
|
393
|
+
raise HTTPException(status_code=404, detail=e.message)
|
|
394
|
+
|
|
395
|
+
return ReviewCommentResponse(
|
|
396
|
+
id=comment.id,
|
|
397
|
+
revision_id=comment.revision_id,
|
|
398
|
+
file_path=comment.file_path,
|
|
399
|
+
line_number=comment.line_number,
|
|
400
|
+
anchor=comment.anchor,
|
|
401
|
+
body=comment.body,
|
|
402
|
+
author_type=comment.author_type_enum,
|
|
403
|
+
resolved=comment.resolved,
|
|
404
|
+
created_at=comment.created_at,
|
|
405
|
+
line_content=comment.line_content,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@router.post(
|
|
410
|
+
"/comments/{comment_id}/unresolve",
|
|
411
|
+
response_model=ReviewCommentResponse,
|
|
412
|
+
summary="Unresolve a comment",
|
|
413
|
+
)
|
|
414
|
+
async def unresolve_comment(
|
|
415
|
+
comment_id: str,
|
|
416
|
+
db: AsyncSession = Depends(get_db),
|
|
417
|
+
) -> ReviewCommentResponse:
|
|
418
|
+
"""Mark a comment as unresolved."""
|
|
419
|
+
service = ReviewService(db)
|
|
420
|
+
try:
|
|
421
|
+
comment = await service.unresolve_comment(comment_id)
|
|
422
|
+
await db.commit()
|
|
423
|
+
except ResourceNotFoundError as e:
|
|
424
|
+
raise HTTPException(status_code=404, detail=e.message)
|
|
425
|
+
|
|
426
|
+
return ReviewCommentResponse(
|
|
427
|
+
id=comment.id,
|
|
428
|
+
revision_id=comment.revision_id,
|
|
429
|
+
file_path=comment.file_path,
|
|
430
|
+
line_number=comment.line_number,
|
|
431
|
+
anchor=comment.anchor,
|
|
432
|
+
body=comment.body,
|
|
433
|
+
author_type=comment.author_type_enum,
|
|
434
|
+
resolved=comment.resolved,
|
|
435
|
+
created_at=comment.created_at,
|
|
436
|
+
line_content=comment.line_content,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# ==================== Review Decision Endpoints ====================
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
@router.post(
|
|
444
|
+
"/revisions/{revision_id}/review",
|
|
445
|
+
response_model=ReviewSummaryResponse,
|
|
446
|
+
status_code=status.HTTP_201_CREATED,
|
|
447
|
+
summary="Submit a review decision for a revision",
|
|
448
|
+
)
|
|
449
|
+
async def submit_review(
|
|
450
|
+
revision_id: str,
|
|
451
|
+
data: ReviewSubmit,
|
|
452
|
+
db: AsyncSession = Depends(get_db),
|
|
453
|
+
) -> ReviewSummaryResponse:
|
|
454
|
+
"""Submit a review decision (approve or request changes) for a revision.
|
|
455
|
+
|
|
456
|
+
If approved:
|
|
457
|
+
- Ticket transitions to 'done'
|
|
458
|
+
|
|
459
|
+
If changes_requested with auto_run_fix=true:
|
|
460
|
+
- Creates a new execute job to address feedback
|
|
461
|
+
- Agent will receive feedback bundle in its prompt
|
|
462
|
+
|
|
463
|
+
Returns 409 Conflict if the revision is superseded.
|
|
464
|
+
"""
|
|
465
|
+
review_service = ReviewService(db)
|
|
466
|
+
revision_service = RevisionService(db)
|
|
467
|
+
|
|
468
|
+
# Initialize merge status (will be populated if merge is attempted)
|
|
469
|
+
merge_attempted = False
|
|
470
|
+
merge_success = None
|
|
471
|
+
merge_message = None
|
|
472
|
+
|
|
473
|
+
try:
|
|
474
|
+
# Get revision to find ticket_id
|
|
475
|
+
revision = await revision_service.get_revision_by_id(revision_id)
|
|
476
|
+
|
|
477
|
+
# Submit the review
|
|
478
|
+
review_summary = await review_service.submit_review(
|
|
479
|
+
revision_id=revision_id,
|
|
480
|
+
decision=ReviewDecision(data.decision.value),
|
|
481
|
+
summary=data.summary,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
# Handle post-review actions
|
|
485
|
+
ticket_service = TicketService(db)
|
|
486
|
+
|
|
487
|
+
if data.decision.value == ReviewDecision.APPROVED.value:
|
|
488
|
+
# Get ticket to check current state
|
|
489
|
+
ticket = await ticket_service.get_ticket_by_id(revision.ticket_id)
|
|
490
|
+
|
|
491
|
+
# Detect target branch from board config or git
|
|
492
|
+
target_branch = "main" # fallback
|
|
493
|
+
board = None
|
|
494
|
+
if ticket.board_id:
|
|
495
|
+
from sqlalchemy import select as sql_select_board
|
|
496
|
+
|
|
497
|
+
from app.models.board import Board
|
|
498
|
+
|
|
499
|
+
board_result = await db.execute(
|
|
500
|
+
sql_select_board(Board).where(Board.id == ticket.board_id)
|
|
501
|
+
)
|
|
502
|
+
board = board_result.scalar_one_or_none()
|
|
503
|
+
if board and board.default_branch:
|
|
504
|
+
target_branch = board.default_branch
|
|
505
|
+
|
|
506
|
+
# CRITICAL: Do NOT transition to DONE yet - it triggers worktree cleanup!
|
|
507
|
+
# We need the worktree to exist for PR creation or merge.
|
|
508
|
+
# Transition happens AFTER merge/PR creation.
|
|
509
|
+
|
|
510
|
+
if data.create_pr:
|
|
511
|
+
# Create a GitHub PR instead of merging directly
|
|
512
|
+
from pathlib import Path
|
|
513
|
+
|
|
514
|
+
from sqlalchemy import select as sql_select
|
|
515
|
+
|
|
516
|
+
from app.models.workspace import Workspace
|
|
517
|
+
from app.services.git_host import get_git_host_provider
|
|
518
|
+
|
|
519
|
+
# Get workspace to find worktree path and branch
|
|
520
|
+
workspace_result = await db.execute(
|
|
521
|
+
sql_select(Workspace).where(
|
|
522
|
+
Workspace.ticket_id == revision.ticket_id
|
|
523
|
+
)
|
|
524
|
+
)
|
|
525
|
+
workspace = workspace_result.scalar_one_or_none()
|
|
526
|
+
|
|
527
|
+
if workspace and workspace.worktree_path:
|
|
528
|
+
try:
|
|
529
|
+
repo_path = Path(workspace.worktree_path)
|
|
530
|
+
git_host = get_git_host_provider(repo_path)
|
|
531
|
+
await git_host.ensure_authenticated()
|
|
532
|
+
|
|
533
|
+
head_branch = workspace.branch_name or f"ticket-{ticket.id[:8]}"
|
|
534
|
+
|
|
535
|
+
pr = await git_host.create_pr(
|
|
536
|
+
repo_path=repo_path,
|
|
537
|
+
title=ticket.title,
|
|
538
|
+
body=(
|
|
539
|
+
f"Implements: {ticket.title}\n\n"
|
|
540
|
+
f"{ticket.description or ''}\n\n"
|
|
541
|
+
f"Ticket ID: {ticket.id}"
|
|
542
|
+
),
|
|
543
|
+
head_branch=head_branch,
|
|
544
|
+
base_branch=target_branch,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
# Update ticket with PR information
|
|
548
|
+
from datetime import UTC, datetime
|
|
549
|
+
|
|
550
|
+
ticket.pr_number = pr.number
|
|
551
|
+
ticket.pr_url = pr.url
|
|
552
|
+
ticket.pr_state = pr.state
|
|
553
|
+
ticket.pr_created_at = datetime.now(UTC)
|
|
554
|
+
ticket.pr_head_branch = pr.head_branch
|
|
555
|
+
ticket.pr_base_branch = pr.base_branch
|
|
556
|
+
|
|
557
|
+
logger.info(
|
|
558
|
+
f"Created PR #{pr.number} for ticket {ticket.id}: {pr.url}"
|
|
559
|
+
)
|
|
560
|
+
except Exception as e:
|
|
561
|
+
logger.warning(
|
|
562
|
+
f"Failed to create PR for ticket {ticket.id}: {e}"
|
|
563
|
+
)
|
|
564
|
+
else:
|
|
565
|
+
logger.warning(
|
|
566
|
+
f"No workspace found for ticket {ticket.id}, skipping PR creation"
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
# Transition to DONE after PR creation (worktree will be kept for PR)
|
|
570
|
+
if ticket.state != TicketState.DONE.value:
|
|
571
|
+
await ticket_service.transition_ticket(
|
|
572
|
+
ticket_id=revision.ticket_id,
|
|
573
|
+
to_state=TicketState.DONE,
|
|
574
|
+
actor_type=TicketActorType.HUMAN,
|
|
575
|
+
reason="Revision approved by reviewer",
|
|
576
|
+
auto_verify=False,
|
|
577
|
+
skip_cleanup=True, # Worktree kept for PR
|
|
578
|
+
)
|
|
579
|
+
else:
|
|
580
|
+
# Auto-merge using simple git operations (no state coupling)
|
|
581
|
+
from datetime import UTC, datetime
|
|
582
|
+
from pathlib import Path
|
|
583
|
+
|
|
584
|
+
from sqlalchemy import select as sql_select
|
|
585
|
+
|
|
586
|
+
from app.models.workspace import Workspace
|
|
587
|
+
from app.services.git_merge_simple import (
|
|
588
|
+
GitMergeError,
|
|
589
|
+
cleanup_worktree,
|
|
590
|
+
git_merge_worktree_branch,
|
|
591
|
+
)
|
|
592
|
+
from app.services.workspace_service import WorkspaceService
|
|
593
|
+
|
|
594
|
+
# Track merge status to return to frontend
|
|
595
|
+
merge_attempted = True
|
|
596
|
+
merge_success = False
|
|
597
|
+
merge_message = None
|
|
598
|
+
|
|
599
|
+
try:
|
|
600
|
+
# Get workspace info
|
|
601
|
+
workspace_result = await db.execute(
|
|
602
|
+
sql_select(Workspace).where(
|
|
603
|
+
Workspace.ticket_id == revision.ticket_id
|
|
604
|
+
)
|
|
605
|
+
)
|
|
606
|
+
workspace = workspace_result.scalar_one_or_none()
|
|
607
|
+
|
|
608
|
+
if not workspace or not workspace.is_active:
|
|
609
|
+
merge_message = "No active workspace found for ticket"
|
|
610
|
+
logger.info(
|
|
611
|
+
f"Skipping merge for ticket {revision.ticket_id}: {merge_message}"
|
|
612
|
+
)
|
|
613
|
+
else:
|
|
614
|
+
worktree_path = Path(workspace.worktree_path)
|
|
615
|
+
branch_name = workspace.branch_name
|
|
616
|
+
|
|
617
|
+
# Get repo path
|
|
618
|
+
workspace_service = WorkspaceService(db)
|
|
619
|
+
repo_path = workspace_service.get_repo_path()
|
|
620
|
+
|
|
621
|
+
# Ensure worktree exists
|
|
622
|
+
if not worktree_path.exists():
|
|
623
|
+
merge_message = f"Worktree does not exist: {worktree_path}"
|
|
624
|
+
logger.warning(
|
|
625
|
+
f"Cannot merge ticket {revision.ticket_id}: {merge_message}"
|
|
626
|
+
)
|
|
627
|
+
else:
|
|
628
|
+
# Simple git merge (runs in thread pool to avoid blocking)
|
|
629
|
+
# Read merge configuration with board-level overrides
|
|
630
|
+
from app.services.config_service import ConfigService
|
|
631
|
+
|
|
632
|
+
config_service = ConfigService()
|
|
633
|
+
|
|
634
|
+
# Reuse board fetched earlier for target_branch detection
|
|
635
|
+
board_config = (
|
|
636
|
+
board.config if board and board.config else None
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
# Load config with board overrides applied
|
|
640
|
+
config = config_service.load_config_with_board_overrides(
|
|
641
|
+
board_config=board_config, use_cache=False
|
|
642
|
+
)
|
|
643
|
+
merge_config = config.merge_config
|
|
644
|
+
|
|
645
|
+
import asyncio
|
|
646
|
+
|
|
647
|
+
merge_result = await asyncio.to_thread(
|
|
648
|
+
git_merge_worktree_branch,
|
|
649
|
+
repo_path=repo_path,
|
|
650
|
+
branch_name=branch_name,
|
|
651
|
+
target_branch=target_branch,
|
|
652
|
+
delete_branch_after=merge_config.delete_branch_after_merge,
|
|
653
|
+
push_to_remote=merge_config.push_after_merge,
|
|
654
|
+
squash=merge_config.squash_merge,
|
|
655
|
+
check_divergence=merge_config.check_divergence,
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
merge_success = merge_result.success
|
|
659
|
+
merge_message = merge_result.message
|
|
660
|
+
logger.info(
|
|
661
|
+
f"Merge result for ticket {revision.ticket_id}: {merge_message}"
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
# Record merge event so merge-status correctly reports is_merged
|
|
665
|
+
if merge_success:
|
|
666
|
+
import json as _json
|
|
667
|
+
|
|
668
|
+
from app.models.ticket_event import TicketEvent
|
|
669
|
+
from app.state_machine import (
|
|
670
|
+
TicketState as SM_TicketState,
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
merge_event = TicketEvent(
|
|
674
|
+
ticket_id=revision.ticket_id,
|
|
675
|
+
event_type="merge_succeeded",
|
|
676
|
+
from_state=SM_TicketState.DONE.value,
|
|
677
|
+
to_state=SM_TicketState.DONE.value,
|
|
678
|
+
actor_type="system",
|
|
679
|
+
actor_id="review_auto_merge",
|
|
680
|
+
reason=f"Auto-merged on approval: {merge_message}",
|
|
681
|
+
payload_json=_json.dumps(
|
|
682
|
+
{
|
|
683
|
+
"strategy": "merge",
|
|
684
|
+
"worktree_branch": branch_name,
|
|
685
|
+
"base_branch": target_branch,
|
|
686
|
+
"auto_merge": True,
|
|
687
|
+
}
|
|
688
|
+
),
|
|
689
|
+
)
|
|
690
|
+
db.add(merge_event)
|
|
691
|
+
|
|
692
|
+
# Cleanup worktree
|
|
693
|
+
if merge_success:
|
|
694
|
+
cleanup_success = await asyncio.to_thread(
|
|
695
|
+
cleanup_worktree,
|
|
696
|
+
repo_path=repo_path,
|
|
697
|
+
worktree_path=worktree_path,
|
|
698
|
+
)
|
|
699
|
+
if cleanup_success:
|
|
700
|
+
# Mark workspace as cleaned up
|
|
701
|
+
workspace.cleaned_up_at = datetime.now(UTC)
|
|
702
|
+
logger.info(
|
|
703
|
+
f"Cleaned up worktree for ticket {revision.ticket_id}"
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
except GitMergeError as e:
|
|
707
|
+
merge_message = f"Git merge failed: {str(e)}"
|
|
708
|
+
logger.error(
|
|
709
|
+
f"Merge error for ticket {revision.ticket_id}: {merge_message}"
|
|
710
|
+
)
|
|
711
|
+
except Exception as e:
|
|
712
|
+
merge_message = f"Unexpected merge error: {str(e)}"
|
|
713
|
+
logger.error(
|
|
714
|
+
f"Unexpected error during merge for ticket {revision.ticket_id}: {e}",
|
|
715
|
+
exc_info=True,
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
# Transition to DONE after merge attempt (even if merge failed - review was approved)
|
|
719
|
+
if ticket.state != TicketState.DONE.value:
|
|
720
|
+
await ticket_service.transition_ticket(
|
|
721
|
+
ticket_id=revision.ticket_id,
|
|
722
|
+
to_state=TicketState.DONE,
|
|
723
|
+
actor_type=TicketActorType.HUMAN,
|
|
724
|
+
reason="Revision approved by reviewer",
|
|
725
|
+
auto_verify=False,
|
|
726
|
+
skip_cleanup=True, # Merge path handles cleanup above
|
|
727
|
+
)
|
|
728
|
+
elif (
|
|
729
|
+
data.decision.value == ReviewDecision.CHANGES_REQUESTED.value
|
|
730
|
+
and data.auto_run_fix
|
|
731
|
+
):
|
|
732
|
+
# Auto-rerun caps to prevent infinite loops:
|
|
733
|
+
# - Max 2 auto-reruns per revision (per source_revision_id)
|
|
734
|
+
# - Max 5 total revisions per ticket overall
|
|
735
|
+
MAX_AUTO_RERUNS_PER_REVISION = 2
|
|
736
|
+
MAX_REVISIONS_PER_TICKET = 5
|
|
737
|
+
|
|
738
|
+
# Check per-revision rerun cap (how many times THIS revision has been addressed)
|
|
739
|
+
from sqlalchemy import select as sql_select
|
|
740
|
+
|
|
741
|
+
rerun_result = await db.execute(
|
|
742
|
+
sql_select(Job).where(Job.source_revision_id == revision_id)
|
|
743
|
+
)
|
|
744
|
+
reruns_from_this_revision = len(list(rerun_result.scalars().all()))
|
|
745
|
+
|
|
746
|
+
if reruns_from_this_revision >= MAX_AUTO_RERUNS_PER_REVISION:
|
|
747
|
+
raise ValidationError(
|
|
748
|
+
f"Maximum auto-reruns ({MAX_AUTO_RERUNS_PER_REVISION}) from this revision reached. "
|
|
749
|
+
"Please manually create an execute job or resolve the feedback differently."
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
# Check per-ticket total revisions cap
|
|
753
|
+
revisions_list = await revision_service.get_revisions_for_ticket(
|
|
754
|
+
revision.ticket_id
|
|
755
|
+
)
|
|
756
|
+
if len(revisions_list) >= MAX_REVISIONS_PER_TICKET:
|
|
757
|
+
raise ValidationError(
|
|
758
|
+
f"Maximum total revisions ({MAX_REVISIONS_PER_TICKET}) for this ticket reached. "
|
|
759
|
+
"Consider creating a new ticket for remaining work."
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
# Transition to executing and create new execute job
|
|
763
|
+
await ticket_service.transition_ticket(
|
|
764
|
+
ticket_id=revision.ticket_id,
|
|
765
|
+
to_state=TicketState.EXECUTING,
|
|
766
|
+
actor_type=TicketActorType.HUMAN,
|
|
767
|
+
reason="Changes requested - triggering agent re-execution",
|
|
768
|
+
auto_verify=False,
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
# Create new execute job with source_revision_id for traceability
|
|
772
|
+
from app.services.task_dispatch import enqueue_task
|
|
773
|
+
|
|
774
|
+
job = Job(
|
|
775
|
+
ticket_id=revision.ticket_id,
|
|
776
|
+
kind=JobKind.EXECUTE.value,
|
|
777
|
+
status=JobStatus.QUEUED.value,
|
|
778
|
+
source_revision_id=revision_id, # Track which revision is being addressed
|
|
779
|
+
)
|
|
780
|
+
db.add(job)
|
|
781
|
+
await db.flush()
|
|
782
|
+
await db.refresh(job)
|
|
783
|
+
|
|
784
|
+
# Commit BEFORE enqueue_task to release the SQLite write lock.
|
|
785
|
+
# enqueue_task opens a separate sqlite3 connection which would
|
|
786
|
+
# deadlock if this session still holds the write lock.
|
|
787
|
+
await db.commit()
|
|
788
|
+
|
|
789
|
+
# Enqueue the execute task (outside write lock)
|
|
790
|
+
task = enqueue_task("execute_ticket", args=[job.id])
|
|
791
|
+
job.celery_task_id = task.id
|
|
792
|
+
await db.commit()
|
|
793
|
+
|
|
794
|
+
except ResourceNotFoundError as e:
|
|
795
|
+
raise HTTPException(status_code=404, detail=e.message)
|
|
796
|
+
except ConflictError as e:
|
|
797
|
+
raise HTTPException(status_code=409, detail=e.message)
|
|
798
|
+
except ValidationError as e:
|
|
799
|
+
raise HTTPException(status_code=400, detail=e.message)
|
|
800
|
+
|
|
801
|
+
return ReviewSummaryResponse(
|
|
802
|
+
id=review_summary.id,
|
|
803
|
+
revision_id=review_summary.revision_id,
|
|
804
|
+
decision=review_summary.decision_enum,
|
|
805
|
+
body=review_summary.body,
|
|
806
|
+
created_at=review_summary.created_at,
|
|
807
|
+
merge_attempted=merge_attempted,
|
|
808
|
+
merge_success=merge_success,
|
|
809
|
+
merge_message=merge_message,
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
@router.get(
|
|
814
|
+
"/revisions/{revision_id}/feedback-bundle",
|
|
815
|
+
response_model=FeedbackBundle,
|
|
816
|
+
summary="Get the feedback bundle for a revision",
|
|
817
|
+
)
|
|
818
|
+
async def get_feedback_bundle(
|
|
819
|
+
revision_id: str,
|
|
820
|
+
db: AsyncSession = Depends(get_db),
|
|
821
|
+
) -> FeedbackBundle:
|
|
822
|
+
"""Get the structured feedback bundle for a revision.
|
|
823
|
+
|
|
824
|
+
This is the feedback that gets injected into the agent prompt
|
|
825
|
+
when creating a new revision after changes are requested.
|
|
826
|
+
"""
|
|
827
|
+
service = ReviewService(db)
|
|
828
|
+
try:
|
|
829
|
+
return await service.get_feedback_bundle(revision_id)
|
|
830
|
+
except ResourceNotFoundError as e:
|
|
831
|
+
raise HTTPException(status_code=404, detail=e.message)
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
@router.get(
|
|
835
|
+
"/revisions/{revision_id}/timeline",
|
|
836
|
+
response_model=RevisionTimelineResponse,
|
|
837
|
+
summary="Get the review timeline for a revision",
|
|
838
|
+
)
|
|
839
|
+
async def get_revision_timeline(
|
|
840
|
+
revision_id: str,
|
|
841
|
+
db: AsyncSession = Depends(get_db),
|
|
842
|
+
) -> RevisionTimelineResponse:
|
|
843
|
+
"""Get the timeline of events for a revision.
|
|
844
|
+
|
|
845
|
+
Shows a chronological feed of:
|
|
846
|
+
- Revision created
|
|
847
|
+
- Comments added
|
|
848
|
+
- Review submitted
|
|
849
|
+
- Jobs queued/completed
|
|
850
|
+
"""
|
|
851
|
+
from sqlalchemy import select as sql_select
|
|
852
|
+
from sqlalchemy.orm import selectinload
|
|
853
|
+
|
|
854
|
+
from app.models.revision import Revision
|
|
855
|
+
|
|
856
|
+
# Get revision with all related data
|
|
857
|
+
result = await db.execute(
|
|
858
|
+
sql_select(Revision)
|
|
859
|
+
.where(Revision.id == revision_id)
|
|
860
|
+
.options(
|
|
861
|
+
selectinload(Revision.comments),
|
|
862
|
+
selectinload(Revision.review_summary),
|
|
863
|
+
selectinload(Revision.job),
|
|
864
|
+
)
|
|
865
|
+
)
|
|
866
|
+
revision = result.scalar_one_or_none()
|
|
867
|
+
if revision is None:
|
|
868
|
+
raise HTTPException(status_code=404, detail=f"Revision {revision_id} not found")
|
|
869
|
+
|
|
870
|
+
events: list[TimelineEvent] = []
|
|
871
|
+
|
|
872
|
+
# Event 1: Revision created
|
|
873
|
+
events.append(
|
|
874
|
+
TimelineEvent(
|
|
875
|
+
id=f"rev-{revision.id}",
|
|
876
|
+
event_type="revision_created",
|
|
877
|
+
actor="agent",
|
|
878
|
+
message=f"Revision {revision.number} created by executor",
|
|
879
|
+
created_at=revision.created_at,
|
|
880
|
+
metadata={"revision_number": revision.number, "job_id": revision.job_id},
|
|
881
|
+
)
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
# Event 2: Comments added
|
|
885
|
+
for comment in revision.comments:
|
|
886
|
+
events.append(
|
|
887
|
+
TimelineEvent(
|
|
888
|
+
id=f"comment-{comment.id}",
|
|
889
|
+
event_type="comment_added",
|
|
890
|
+
actor=comment.author_type,
|
|
891
|
+
message=f"Comment on {comment.file_path}:{comment.line_number}",
|
|
892
|
+
created_at=comment.created_at,
|
|
893
|
+
metadata={
|
|
894
|
+
"file_path": comment.file_path,
|
|
895
|
+
"line_number": comment.line_number,
|
|
896
|
+
"resolved": comment.resolved,
|
|
897
|
+
},
|
|
898
|
+
)
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
# Event 3: Review submitted
|
|
902
|
+
if revision.review_summary:
|
|
903
|
+
events.append(
|
|
904
|
+
TimelineEvent(
|
|
905
|
+
id=f"review-{revision.review_summary.id}",
|
|
906
|
+
event_type="review_submitted",
|
|
907
|
+
actor="human",
|
|
908
|
+
message=f"Review: {revision.review_summary.decision}",
|
|
909
|
+
created_at=revision.review_summary.created_at,
|
|
910
|
+
metadata={"decision": revision.review_summary.decision},
|
|
911
|
+
)
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
# Event 4: Follow-up jobs (jobs with source_revision_id = this revision)
|
|
915
|
+
followup_result = await db.execute(
|
|
916
|
+
sql_select(Job).where(Job.source_revision_id == revision_id)
|
|
917
|
+
)
|
|
918
|
+
followup_jobs = list(followup_result.scalars().all())
|
|
919
|
+
for job in followup_jobs:
|
|
920
|
+
events.append(
|
|
921
|
+
TimelineEvent(
|
|
922
|
+
id=f"job-{job.id}",
|
|
923
|
+
event_type="job_queued" if job.status == "queued" else "job_completed",
|
|
924
|
+
actor="system",
|
|
925
|
+
message=f"Auto rerun queued (job {job.id[:8]}...)"
|
|
926
|
+
if job.status == "queued"
|
|
927
|
+
else f"Auto rerun {job.status}",
|
|
928
|
+
created_at=job.created_at,
|
|
929
|
+
metadata={"job_id": job.id, "job_status": job.status},
|
|
930
|
+
)
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
# Sort events by created_at
|
|
934
|
+
events.sort(key=lambda e: e.created_at)
|
|
935
|
+
|
|
936
|
+
return RevisionTimelineResponse(
|
|
937
|
+
revision_id=revision_id,
|
|
938
|
+
events=events,
|
|
939
|
+
)
|