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,618 @@
|
|
|
1
|
+
"""Canary tests for revision invariants.
|
|
2
|
+
|
|
3
|
+
These tests validate the critical invariants that must hold for the PR-like review system:
|
|
4
|
+
1. Revision creation is idempotent (same job_id doesn't create duplicate revisions)
|
|
5
|
+
2. At most one revision can be 'open' per ticket
|
|
6
|
+
3. Approval is blocked if unresolved comments exist (server-side)
|
|
7
|
+
4. Feedback bundle includes all unresolved comments
|
|
8
|
+
5. Orphaned comments are preserved (not dropped)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from sqlalchemy import select
|
|
13
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
14
|
+
|
|
15
|
+
from app.models.goal import Goal
|
|
16
|
+
from app.models.job import Job, JobKind, JobStatus
|
|
17
|
+
from app.models.review_comment import AuthorType
|
|
18
|
+
from app.models.review_summary import ReviewDecision
|
|
19
|
+
from app.models.revision import Revision, RevisionStatus
|
|
20
|
+
from app.models.ticket import Ticket
|
|
21
|
+
from app.services.review_service import ReviewService
|
|
22
|
+
from app.services.revision_service import RevisionService
|
|
23
|
+
|
|
24
|
+
# ==================== Test Fixtures ====================
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
async def sample_goal(db: AsyncSession) -> Goal:
|
|
29
|
+
"""Create a sample goal for testing."""
|
|
30
|
+
goal = Goal(
|
|
31
|
+
title="Test Goal",
|
|
32
|
+
description="Test goal for revision invariants",
|
|
33
|
+
)
|
|
34
|
+
db.add(goal)
|
|
35
|
+
await db.flush()
|
|
36
|
+
await db.refresh(goal)
|
|
37
|
+
return goal
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.fixture
|
|
41
|
+
async def sample_ticket(db: AsyncSession, sample_goal: Goal) -> Ticket:
|
|
42
|
+
"""Create a sample ticket for testing."""
|
|
43
|
+
ticket = Ticket(
|
|
44
|
+
title="Test Ticket",
|
|
45
|
+
description="Test ticket for revision invariants",
|
|
46
|
+
state="executing",
|
|
47
|
+
goal_id=sample_goal.id,
|
|
48
|
+
)
|
|
49
|
+
db.add(ticket)
|
|
50
|
+
await db.flush()
|
|
51
|
+
await db.refresh(ticket)
|
|
52
|
+
return ticket
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.fixture
|
|
56
|
+
async def sample_job(db: AsyncSession, sample_ticket: Ticket) -> Job:
|
|
57
|
+
"""Create a sample job for testing."""
|
|
58
|
+
job = Job(
|
|
59
|
+
ticket_id=sample_ticket.id,
|
|
60
|
+
kind=JobKind.EXECUTE.value,
|
|
61
|
+
status=JobStatus.SUCCEEDED.value,
|
|
62
|
+
)
|
|
63
|
+
db.add(job)
|
|
64
|
+
await db.flush()
|
|
65
|
+
await db.refresh(job)
|
|
66
|
+
return job
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ==================== Test 1: Revision Idempotency ====================
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def test_revision_idempotency_constraint_prevents_duplicates(
|
|
73
|
+
db: AsyncSession, sample_ticket: Ticket, sample_job: Job
|
|
74
|
+
):
|
|
75
|
+
"""Test that the same job cannot create two revisions.
|
|
76
|
+
|
|
77
|
+
If the same execute job is retried, we must not create Revision N twice.
|
|
78
|
+
The unique constraint on (ticket_id, job_id) should prevent this.
|
|
79
|
+
"""
|
|
80
|
+
revision_service = RevisionService(db)
|
|
81
|
+
|
|
82
|
+
# Create first revision
|
|
83
|
+
await revision_service.create_revision(
|
|
84
|
+
ticket_id=sample_ticket.id,
|
|
85
|
+
job_id=sample_job.id,
|
|
86
|
+
)
|
|
87
|
+
await db.commit()
|
|
88
|
+
|
|
89
|
+
# Attempt to create second revision with same job_id
|
|
90
|
+
# This should raise an IntegrityError due to unique constraint
|
|
91
|
+
from sqlalchemy.exc import IntegrityError
|
|
92
|
+
|
|
93
|
+
with pytest.raises(IntegrityError):
|
|
94
|
+
await revision_service.create_revision(
|
|
95
|
+
ticket_id=sample_ticket.id,
|
|
96
|
+
job_id=sample_job.id, # Same job_id!
|
|
97
|
+
)
|
|
98
|
+
await db.commit()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ==================== Test 2: Two Open Revisions Prevention ====================
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def test_at_most_one_open_revision_per_ticket(
|
|
105
|
+
db: AsyncSession, sample_ticket: Ticket
|
|
106
|
+
):
|
|
107
|
+
"""Test that creating a new revision supersedes the previous open revision.
|
|
108
|
+
|
|
109
|
+
For a ticket:
|
|
110
|
+
- At most 1 revision can be 'open'
|
|
111
|
+
- Creating a new revision must supersede the previous open revision in the same transaction
|
|
112
|
+
"""
|
|
113
|
+
revision_service = RevisionService(db)
|
|
114
|
+
|
|
115
|
+
# Create first job and revision
|
|
116
|
+
job1 = Job(
|
|
117
|
+
ticket_id=sample_ticket.id,
|
|
118
|
+
kind=JobKind.EXECUTE.value,
|
|
119
|
+
status=JobStatus.SUCCEEDED.value,
|
|
120
|
+
)
|
|
121
|
+
db.add(job1)
|
|
122
|
+
await db.flush()
|
|
123
|
+
|
|
124
|
+
revision1 = await revision_service.create_revision(
|
|
125
|
+
ticket_id=sample_ticket.id,
|
|
126
|
+
job_id=job1.id,
|
|
127
|
+
)
|
|
128
|
+
await db.commit()
|
|
129
|
+
|
|
130
|
+
# Verify rev1 is open
|
|
131
|
+
await db.refresh(revision1)
|
|
132
|
+
assert revision1.status == RevisionStatus.OPEN.value
|
|
133
|
+
|
|
134
|
+
# Create second job and revision
|
|
135
|
+
job2 = Job(
|
|
136
|
+
ticket_id=sample_ticket.id,
|
|
137
|
+
kind=JobKind.EXECUTE.value,
|
|
138
|
+
status=JobStatus.SUCCEEDED.value,
|
|
139
|
+
)
|
|
140
|
+
db.add(job2)
|
|
141
|
+
await db.flush()
|
|
142
|
+
|
|
143
|
+
revision2 = await revision_service.create_revision(
|
|
144
|
+
ticket_id=sample_ticket.id,
|
|
145
|
+
job_id=job2.id,
|
|
146
|
+
)
|
|
147
|
+
await db.commit()
|
|
148
|
+
|
|
149
|
+
# Refresh rev1 and verify it's superseded
|
|
150
|
+
await db.refresh(revision1)
|
|
151
|
+
assert revision1.status == RevisionStatus.SUPERSEDED.value, (
|
|
152
|
+
"Previous open revision should be superseded"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Verify rev2 is open
|
|
156
|
+
assert revision2.status == RevisionStatus.OPEN.value
|
|
157
|
+
|
|
158
|
+
# Count open revisions - must be exactly 1
|
|
159
|
+
result = await db.execute(
|
|
160
|
+
select(Revision).where(
|
|
161
|
+
Revision.ticket_id == sample_ticket.id,
|
|
162
|
+
Revision.status == RevisionStatus.OPEN.value,
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
open_revisions = result.scalars().all()
|
|
166
|
+
assert len(open_revisions) == 1, (
|
|
167
|
+
f"Expected exactly 1 open revision, got {len(open_revisions)}"
|
|
168
|
+
)
|
|
169
|
+
assert open_revisions[0].id == revision2.id
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ==================== Test 3: Approval Gating ====================
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def test_approval_allowed_with_unresolved_comments(
|
|
176
|
+
db: AsyncSession, sample_ticket: Ticket, sample_job: Job
|
|
177
|
+
):
|
|
178
|
+
"""Test that approval succeeds even with unresolved comments.
|
|
179
|
+
|
|
180
|
+
The review service deliberately allows approval with unresolved comments.
|
|
181
|
+
Comments are informational; approving accepts the changes regardless.
|
|
182
|
+
"""
|
|
183
|
+
revision_service = RevisionService(db)
|
|
184
|
+
review_service = ReviewService(db)
|
|
185
|
+
|
|
186
|
+
# Create revision
|
|
187
|
+
revision = await revision_service.create_revision(
|
|
188
|
+
ticket_id=sample_ticket.id,
|
|
189
|
+
job_id=sample_job.id,
|
|
190
|
+
)
|
|
191
|
+
await db.commit()
|
|
192
|
+
|
|
193
|
+
# Add an unresolved comment
|
|
194
|
+
await review_service.add_comment(
|
|
195
|
+
revision_id=revision.id,
|
|
196
|
+
file_path="src/example.py",
|
|
197
|
+
line_number=42,
|
|
198
|
+
body="This needs to be fixed",
|
|
199
|
+
author_type=AuthorType.HUMAN,
|
|
200
|
+
)
|
|
201
|
+
await db.commit()
|
|
202
|
+
|
|
203
|
+
# Approval should succeed despite unresolved comments
|
|
204
|
+
summary = await review_service.submit_review(
|
|
205
|
+
revision_id=revision.id,
|
|
206
|
+
decision=ReviewDecision.APPROVED,
|
|
207
|
+
summary="LGTM",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
assert summary is not None
|
|
211
|
+
assert summary.decision == ReviewDecision.APPROVED.value
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
async def test_approval_succeeds_when_all_comments_resolved(
|
|
215
|
+
db: AsyncSession, sample_ticket: Ticket, sample_job: Job
|
|
216
|
+
):
|
|
217
|
+
"""Test that approval succeeds when all comments are resolved."""
|
|
218
|
+
revision_service = RevisionService(db)
|
|
219
|
+
review_service = ReviewService(db)
|
|
220
|
+
|
|
221
|
+
# Create revision
|
|
222
|
+
revision = await revision_service.create_revision(
|
|
223
|
+
ticket_id=sample_ticket.id,
|
|
224
|
+
job_id=sample_job.id,
|
|
225
|
+
)
|
|
226
|
+
await db.commit()
|
|
227
|
+
|
|
228
|
+
# Add a comment
|
|
229
|
+
comment = await review_service.add_comment(
|
|
230
|
+
revision_id=revision.id,
|
|
231
|
+
file_path="src/example.py",
|
|
232
|
+
line_number=42,
|
|
233
|
+
body="This needs to be fixed",
|
|
234
|
+
author_type=AuthorType.HUMAN,
|
|
235
|
+
)
|
|
236
|
+
await db.commit()
|
|
237
|
+
|
|
238
|
+
# Resolve the comment
|
|
239
|
+
await review_service.resolve_comment(comment.id)
|
|
240
|
+
await db.commit()
|
|
241
|
+
|
|
242
|
+
# Now approval should succeed
|
|
243
|
+
review_summary = await review_service.submit_review(
|
|
244
|
+
revision_id=revision.id,
|
|
245
|
+
decision=ReviewDecision.APPROVED,
|
|
246
|
+
summary="LGTM",
|
|
247
|
+
)
|
|
248
|
+
await db.commit()
|
|
249
|
+
|
|
250
|
+
assert review_summary.decision == ReviewDecision.APPROVED.value
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ==================== Test 4: Feedback Injection Correctness ====================
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
async def test_feedback_bundle_contains_unresolved_comments(
|
|
257
|
+
db: AsyncSession, sample_ticket: Ticket, sample_job: Job
|
|
258
|
+
):
|
|
259
|
+
"""Test that feedback bundle includes all unresolved comments.
|
|
260
|
+
|
|
261
|
+
When changes are requested:
|
|
262
|
+
- The feedback bundle must include review summary
|
|
263
|
+
- Only unresolved comments should be included (or resolved with flag)
|
|
264
|
+
"""
|
|
265
|
+
revision_service = RevisionService(db)
|
|
266
|
+
review_service = ReviewService(db)
|
|
267
|
+
|
|
268
|
+
# Save IDs upfront before any operations that might expire them
|
|
269
|
+
ticket_id = sample_ticket.id
|
|
270
|
+
|
|
271
|
+
# Create revision
|
|
272
|
+
revision = await revision_service.create_revision(
|
|
273
|
+
ticket_id=ticket_id,
|
|
274
|
+
job_id=sample_job.id,
|
|
275
|
+
)
|
|
276
|
+
revision_id = revision.id
|
|
277
|
+
await db.commit()
|
|
278
|
+
|
|
279
|
+
# Add two comments
|
|
280
|
+
comment1 = await review_service.add_comment(
|
|
281
|
+
revision_id=revision_id,
|
|
282
|
+
file_path="src/example.py",
|
|
283
|
+
line_number=42,
|
|
284
|
+
body="Rename this variable",
|
|
285
|
+
author_type=AuthorType.HUMAN,
|
|
286
|
+
)
|
|
287
|
+
await review_service.add_comment(
|
|
288
|
+
revision_id=revision_id,
|
|
289
|
+
file_path="src/helper.py",
|
|
290
|
+
line_number=10,
|
|
291
|
+
body="Add error handling here",
|
|
292
|
+
author_type=AuthorType.HUMAN,
|
|
293
|
+
)
|
|
294
|
+
await db.commit()
|
|
295
|
+
|
|
296
|
+
# Resolve one comment
|
|
297
|
+
await review_service.resolve_comment(comment1.id)
|
|
298
|
+
await db.commit()
|
|
299
|
+
|
|
300
|
+
# Request changes
|
|
301
|
+
await review_service.submit_review(
|
|
302
|
+
revision_id=revision_id,
|
|
303
|
+
decision=ReviewDecision.CHANGES_REQUESTED,
|
|
304
|
+
summary="Please address the remaining issue",
|
|
305
|
+
)
|
|
306
|
+
await db.commit()
|
|
307
|
+
|
|
308
|
+
# Expire cached objects to force a fresh query
|
|
309
|
+
db.expire_all()
|
|
310
|
+
|
|
311
|
+
# Get feedback bundle
|
|
312
|
+
feedback = await review_service.get_feedback_bundle(revision_id)
|
|
313
|
+
|
|
314
|
+
# Verify feedback bundle structure
|
|
315
|
+
assert feedback.ticket_id == ticket_id
|
|
316
|
+
assert feedback.revision_id == revision_id
|
|
317
|
+
assert feedback.decision == "changes_requested"
|
|
318
|
+
assert feedback.summary == "Please address the remaining issue"
|
|
319
|
+
|
|
320
|
+
# Only unresolved comment should be in the bundle
|
|
321
|
+
assert len(feedback.comments) == 1
|
|
322
|
+
assert feedback.comments[0].file_path == "src/helper.py"
|
|
323
|
+
assert feedback.comments[0].body == "Add error handling here"
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ==================== Test 5: Orphaned Comment Behavior ====================
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
async def test_orphaned_comments_included_in_feedback_bundle(
|
|
330
|
+
db: AsyncSession, sample_ticket: Ticket, sample_job: Job
|
|
331
|
+
):
|
|
332
|
+
"""Test that comments whose anchors can't be found are still included.
|
|
333
|
+
|
|
334
|
+
When a comment is on a line that's been removed in a new revision:
|
|
335
|
+
- Comment should show as orphaned (via the orphaned flag)
|
|
336
|
+
- Comment should still be included in feedback bundle
|
|
337
|
+
- Comment should NOT be dropped
|
|
338
|
+
"""
|
|
339
|
+
revision_service = RevisionService(db)
|
|
340
|
+
review_service = ReviewService(db)
|
|
341
|
+
|
|
342
|
+
# Create revision
|
|
343
|
+
revision = await revision_service.create_revision(
|
|
344
|
+
ticket_id=sample_ticket.id,
|
|
345
|
+
job_id=sample_job.id,
|
|
346
|
+
)
|
|
347
|
+
await db.commit()
|
|
348
|
+
|
|
349
|
+
# Add a comment with specific anchor data
|
|
350
|
+
await review_service.add_comment(
|
|
351
|
+
revision_id=revision.id,
|
|
352
|
+
file_path="src/old_file.py",
|
|
353
|
+
line_number=100, # Line that might not exist after rerun
|
|
354
|
+
body="This function is inefficient",
|
|
355
|
+
author_type=AuthorType.HUMAN,
|
|
356
|
+
hunk_header="@@ -90,10 +90,15 @@",
|
|
357
|
+
line_content="def slow_function():",
|
|
358
|
+
)
|
|
359
|
+
await db.commit()
|
|
360
|
+
|
|
361
|
+
# Request changes
|
|
362
|
+
await review_service.submit_review(
|
|
363
|
+
revision_id=revision.id,
|
|
364
|
+
decision=ReviewDecision.CHANGES_REQUESTED,
|
|
365
|
+
summary="Please optimize",
|
|
366
|
+
)
|
|
367
|
+
await db.commit()
|
|
368
|
+
|
|
369
|
+
# Get feedback bundle - comment should be present
|
|
370
|
+
feedback = await review_service.get_feedback_bundle(revision.id)
|
|
371
|
+
|
|
372
|
+
# The comment must be included (not dropped!)
|
|
373
|
+
assert len(feedback.comments) == 1
|
|
374
|
+
assert feedback.comments[0].file_path == "src/old_file.py"
|
|
375
|
+
assert feedback.comments[0].body == "This function is inefficient"
|
|
376
|
+
# The anchor should be preserved for matching
|
|
377
|
+
assert feedback.comments[0].anchor is not None
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# ==================== Test: Auto-rerun Cap ====================
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
async def test_auto_rerun_cap_enforced(db: AsyncSession, sample_goal: Goal):
|
|
384
|
+
"""Test that auto-reruns are capped to prevent infinite loops.
|
|
385
|
+
|
|
386
|
+
Caps:
|
|
387
|
+
- Max 2 auto-reruns PER REVISION (per source_revision_id)
|
|
388
|
+
- Max 5 total revisions per ticket
|
|
389
|
+
|
|
390
|
+
After max reached: require explicit human action.
|
|
391
|
+
"""
|
|
392
|
+
# This test validates the logic exists, but the actual cap is enforced
|
|
393
|
+
# in the router endpoint. We test the count logic here.
|
|
394
|
+
|
|
395
|
+
ticket = Ticket(
|
|
396
|
+
title="Rerun Test Ticket",
|
|
397
|
+
description="Test ticket for rerun cap",
|
|
398
|
+
state="needs_human",
|
|
399
|
+
goal_id=sample_goal.id,
|
|
400
|
+
)
|
|
401
|
+
db.add(ticket)
|
|
402
|
+
await db.flush()
|
|
403
|
+
|
|
404
|
+
revision_service = RevisionService(db)
|
|
405
|
+
|
|
406
|
+
# Create first revision (from initial job)
|
|
407
|
+
job1 = Job(
|
|
408
|
+
ticket_id=ticket.id,
|
|
409
|
+
kind=JobKind.EXECUTE.value,
|
|
410
|
+
status=JobStatus.SUCCEEDED.value,
|
|
411
|
+
)
|
|
412
|
+
db.add(job1)
|
|
413
|
+
await db.flush()
|
|
414
|
+
|
|
415
|
+
revision1 = await revision_service.create_revision(
|
|
416
|
+
ticket_id=ticket.id,
|
|
417
|
+
job_id=job1.id,
|
|
418
|
+
)
|
|
419
|
+
await db.commit()
|
|
420
|
+
|
|
421
|
+
# Simulate 2 auto-reruns from revision 1 (max per revision)
|
|
422
|
+
for _i in range(2):
|
|
423
|
+
job = Job(
|
|
424
|
+
ticket_id=ticket.id,
|
|
425
|
+
kind=JobKind.EXECUTE.value,
|
|
426
|
+
status=JobStatus.SUCCEEDED.value,
|
|
427
|
+
source_revision_id=revision1.id, # Addressing revision 1
|
|
428
|
+
)
|
|
429
|
+
db.add(job)
|
|
430
|
+
await db.flush()
|
|
431
|
+
|
|
432
|
+
await revision_service.create_revision(
|
|
433
|
+
ticket_id=ticket.id,
|
|
434
|
+
job_id=job.id,
|
|
435
|
+
)
|
|
436
|
+
await db.commit()
|
|
437
|
+
|
|
438
|
+
# Count jobs that addressed revision 1
|
|
439
|
+
jobs_from_rev1 = await db.execute(
|
|
440
|
+
select(Job).where(Job.source_revision_id == revision1.id)
|
|
441
|
+
)
|
|
442
|
+
reruns_from_rev1 = len(list(jobs_from_rev1.scalars().all()))
|
|
443
|
+
|
|
444
|
+
assert reruns_from_rev1 == 2, "Should have 2 auto-reruns from revision 1"
|
|
445
|
+
# The router logic checks: if reruns_from_this_revision >= 2, reject
|
|
446
|
+
# A 3rd auto-rerun FROM THE SAME REVISION should be blocked
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# ==================== Test: Job Source Revision Traceability ====================
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
async def test_job_source_revision_traceability(
|
|
453
|
+
db: AsyncSession, sample_ticket: Ticket, sample_job: Job
|
|
454
|
+
):
|
|
455
|
+
"""Test that jobs triggered by review have source_revision_id set."""
|
|
456
|
+
revision_service = RevisionService(db)
|
|
457
|
+
|
|
458
|
+
# Create initial revision
|
|
459
|
+
revision = await revision_service.create_revision(
|
|
460
|
+
ticket_id=sample_ticket.id,
|
|
461
|
+
job_id=sample_job.id,
|
|
462
|
+
)
|
|
463
|
+
await db.commit()
|
|
464
|
+
|
|
465
|
+
# Create a new job triggered by review (simulating what the router does)
|
|
466
|
+
new_job = Job(
|
|
467
|
+
ticket_id=sample_ticket.id,
|
|
468
|
+
kind=JobKind.EXECUTE.value,
|
|
469
|
+
status=JobStatus.QUEUED.value,
|
|
470
|
+
source_revision_id=revision.id, # Traceability link
|
|
471
|
+
)
|
|
472
|
+
db.add(new_job)
|
|
473
|
+
await db.commit()
|
|
474
|
+
|
|
475
|
+
# Verify the traceability link
|
|
476
|
+
await db.refresh(new_job)
|
|
477
|
+
assert new_job.source_revision_id == revision.id, (
|
|
478
|
+
"Job should have source_revision_id linking to the revision being addressed"
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# ==================== Test: Superseded Revision Guards ====================
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
async def test_cannot_add_comment_to_superseded_revision(
|
|
486
|
+
db: AsyncSession, sample_goal: Goal
|
|
487
|
+
):
|
|
488
|
+
"""Test that adding comments to superseded revisions is blocked.
|
|
489
|
+
|
|
490
|
+
When a new revision is created, old revisions become superseded.
|
|
491
|
+
Comments on superseded revisions should return 409 Conflict.
|
|
492
|
+
"""
|
|
493
|
+
from app.exceptions import ConflictError
|
|
494
|
+
|
|
495
|
+
ticket = Ticket(
|
|
496
|
+
title="Supersede Test Ticket",
|
|
497
|
+
description="Test ticket for supersede guards",
|
|
498
|
+
state="needs_human",
|
|
499
|
+
goal_id=sample_goal.id,
|
|
500
|
+
)
|
|
501
|
+
db.add(ticket)
|
|
502
|
+
await db.flush()
|
|
503
|
+
|
|
504
|
+
revision_service = RevisionService(db)
|
|
505
|
+
review_service = ReviewService(db)
|
|
506
|
+
|
|
507
|
+
# Create first revision
|
|
508
|
+
job1 = Job(
|
|
509
|
+
ticket_id=ticket.id,
|
|
510
|
+
kind=JobKind.EXECUTE.value,
|
|
511
|
+
status=JobStatus.SUCCEEDED.value,
|
|
512
|
+
)
|
|
513
|
+
db.add(job1)
|
|
514
|
+
await db.flush()
|
|
515
|
+
|
|
516
|
+
revision1 = await revision_service.create_revision(
|
|
517
|
+
ticket_id=ticket.id,
|
|
518
|
+
job_id=job1.id,
|
|
519
|
+
)
|
|
520
|
+
revision1_id = revision1.id
|
|
521
|
+
await db.commit()
|
|
522
|
+
|
|
523
|
+
# Create second revision (this supersedes revision1)
|
|
524
|
+
job2 = Job(
|
|
525
|
+
ticket_id=ticket.id,
|
|
526
|
+
kind=JobKind.EXECUTE.value,
|
|
527
|
+
status=JobStatus.SUCCEEDED.value,
|
|
528
|
+
)
|
|
529
|
+
db.add(job2)
|
|
530
|
+
await db.flush()
|
|
531
|
+
|
|
532
|
+
await revision_service.create_revision(
|
|
533
|
+
ticket_id=ticket.id,
|
|
534
|
+
job_id=job2.id,
|
|
535
|
+
)
|
|
536
|
+
await db.commit()
|
|
537
|
+
|
|
538
|
+
# Verify revision1 is now superseded
|
|
539
|
+
db.expire_all()
|
|
540
|
+
result = await db.execute(select(Revision).where(Revision.id == revision1_id))
|
|
541
|
+
revision1_refreshed = result.scalar_one()
|
|
542
|
+
assert revision1_refreshed.status == "superseded"
|
|
543
|
+
|
|
544
|
+
# Attempt to add comment to superseded revision - should fail
|
|
545
|
+
with pytest.raises(ConflictError) as exc_info:
|
|
546
|
+
await review_service.add_comment(
|
|
547
|
+
revision_id=revision1_id,
|
|
548
|
+
file_path="src/example.py",
|
|
549
|
+
line_number=42,
|
|
550
|
+
body="This should fail",
|
|
551
|
+
author_type=AuthorType.HUMAN,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
assert "superseded" in str(exc_info.value).lower()
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
async def test_cannot_submit_review_to_superseded_revision(
|
|
558
|
+
db: AsyncSession, sample_goal: Goal
|
|
559
|
+
):
|
|
560
|
+
"""Test that submitting reviews to superseded revisions is blocked.
|
|
561
|
+
|
|
562
|
+
When a new revision is created, old revisions become superseded.
|
|
563
|
+
Reviews on superseded revisions should return 409 Conflict.
|
|
564
|
+
"""
|
|
565
|
+
from app.exceptions import ConflictError
|
|
566
|
+
|
|
567
|
+
ticket = Ticket(
|
|
568
|
+
title="Supersede Review Test Ticket",
|
|
569
|
+
description="Test ticket for supersede review guards",
|
|
570
|
+
state="needs_human",
|
|
571
|
+
goal_id=sample_goal.id,
|
|
572
|
+
)
|
|
573
|
+
db.add(ticket)
|
|
574
|
+
await db.flush()
|
|
575
|
+
|
|
576
|
+
revision_service = RevisionService(db)
|
|
577
|
+
review_service = ReviewService(db)
|
|
578
|
+
|
|
579
|
+
# Create first revision
|
|
580
|
+
job1 = Job(
|
|
581
|
+
ticket_id=ticket.id,
|
|
582
|
+
kind=JobKind.EXECUTE.value,
|
|
583
|
+
status=JobStatus.SUCCEEDED.value,
|
|
584
|
+
)
|
|
585
|
+
db.add(job1)
|
|
586
|
+
await db.flush()
|
|
587
|
+
|
|
588
|
+
revision1 = await revision_service.create_revision(
|
|
589
|
+
ticket_id=ticket.id,
|
|
590
|
+
job_id=job1.id,
|
|
591
|
+
)
|
|
592
|
+
revision1_id = revision1.id
|
|
593
|
+
await db.commit()
|
|
594
|
+
|
|
595
|
+
# Create second revision (this supersedes revision1)
|
|
596
|
+
job2 = Job(
|
|
597
|
+
ticket_id=ticket.id,
|
|
598
|
+
kind=JobKind.EXECUTE.value,
|
|
599
|
+
status=JobStatus.SUCCEEDED.value,
|
|
600
|
+
)
|
|
601
|
+
db.add(job2)
|
|
602
|
+
await db.flush()
|
|
603
|
+
|
|
604
|
+
await revision_service.create_revision(
|
|
605
|
+
ticket_id=ticket.id,
|
|
606
|
+
job_id=job2.id,
|
|
607
|
+
)
|
|
608
|
+
await db.commit()
|
|
609
|
+
|
|
610
|
+
# Attempt to submit review to superseded revision - should fail
|
|
611
|
+
with pytest.raises(ConflictError) as exc_info:
|
|
612
|
+
await review_service.submit_review(
|
|
613
|
+
revision_id=revision1_id,
|
|
614
|
+
decision=ReviewDecision.APPROVED,
|
|
615
|
+
summary="This should fail",
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
assert "superseded" in str(exc_info.value).lower()
|