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,379 @@
|
|
|
1
|
+
"""LangChain tools for UDAR agent.
|
|
2
|
+
|
|
3
|
+
These tools wrap existing Draft services to make them accessible
|
|
4
|
+
to the LangGraph agent. All tools are designed to be deterministic and
|
|
5
|
+
minimize LLM calls where possible.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from langchain_core.tools import tool
|
|
12
|
+
from sqlalchemy import select
|
|
13
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
14
|
+
|
|
15
|
+
from app.models.goal import Goal
|
|
16
|
+
from app.models.ticket import Ticket
|
|
17
|
+
from app.services.context_gatherer import ContextGatherer
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@tool
|
|
21
|
+
async def analyze_codebase(repo_root: str) -> str:
|
|
22
|
+
"""Analyze repository structure and gather codebase context.
|
|
23
|
+
|
|
24
|
+
This is a DETERMINISTIC tool (0 LLM calls). It uses the existing
|
|
25
|
+
ContextGatherer to scan the repository and return metadata about:
|
|
26
|
+
- Project type (python, node, mixed, etc.)
|
|
27
|
+
- File tree with line counts
|
|
28
|
+
- TODO comments
|
|
29
|
+
- README excerpt
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
repo_root: Absolute path to repository root
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
JSON string with codebase context:
|
|
36
|
+
{
|
|
37
|
+
"project_type": "python",
|
|
38
|
+
"file_count": 150,
|
|
39
|
+
"total_lines": 12500,
|
|
40
|
+
"todo_count": 23,
|
|
41
|
+
"has_readme": true,
|
|
42
|
+
"readme_excerpt": "...",
|
|
43
|
+
"file_tree_sample": ["backend/app/main.py", ...]
|
|
44
|
+
}
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
gatherer = ContextGatherer(max_files=1000)
|
|
48
|
+
context = gatherer.gather(
|
|
49
|
+
repo_root=Path(repo_root),
|
|
50
|
+
include_readme_excerpt=True,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Convert to JSON-serializable format
|
|
54
|
+
result = {
|
|
55
|
+
"project_type": context.project_type,
|
|
56
|
+
"file_count": len(context.file_tree),
|
|
57
|
+
"total_lines": sum(f.line_count for f in context.file_tree),
|
|
58
|
+
"todo_count": context.todo_count,
|
|
59
|
+
"has_readme": bool(context.readme_excerpt),
|
|
60
|
+
"readme_excerpt": context.readme_excerpt[:500]
|
|
61
|
+
if context.readme_excerpt
|
|
62
|
+
else None,
|
|
63
|
+
"file_tree_sample": [
|
|
64
|
+
f.path for f in context.file_tree[:50]
|
|
65
|
+
], # Cap at 50 files
|
|
66
|
+
"stats": {
|
|
67
|
+
"files_scanned": context.stats.files_scanned,
|
|
68
|
+
"bytes_read": context.stats.bytes_read,
|
|
69
|
+
"excluded_count": context.stats.skipped_excluded,
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return json.dumps(result, indent=2)
|
|
74
|
+
|
|
75
|
+
except Exception as e:
|
|
76
|
+
return json.dumps(
|
|
77
|
+
{
|
|
78
|
+
"error": str(e),
|
|
79
|
+
"project_type": "unknown",
|
|
80
|
+
"file_count": 0,
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@tool
|
|
86
|
+
async def search_tickets(
|
|
87
|
+
db: AsyncSession,
|
|
88
|
+
goal_id: str,
|
|
89
|
+
query: str | None = None,
|
|
90
|
+
state: str | None = None,
|
|
91
|
+
) -> str:
|
|
92
|
+
"""Search existing tickets for a goal.
|
|
93
|
+
|
|
94
|
+
This is a DETERMINISTIC tool (0 LLM calls). It queries the database
|
|
95
|
+
to find tickets matching the criteria. Useful for avoiding duplicate
|
|
96
|
+
ticket generation.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
db: Database session
|
|
100
|
+
goal_id: Goal ID to search within
|
|
101
|
+
query: Optional text to search in title/description
|
|
102
|
+
state: Optional state filter (e.g., "done", "planned")
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
JSON string with ticket list:
|
|
106
|
+
{
|
|
107
|
+
"total": 5,
|
|
108
|
+
"tickets": [
|
|
109
|
+
{
|
|
110
|
+
"id": "abc-123",
|
|
111
|
+
"title": "Add authentication",
|
|
112
|
+
"state": "done",
|
|
113
|
+
"priority": 90,
|
|
114
|
+
"blocked_by_ticket_id": null
|
|
115
|
+
},
|
|
116
|
+
...
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
# Build query
|
|
122
|
+
stmt = select(Ticket).where(Ticket.goal_id == goal_id)
|
|
123
|
+
|
|
124
|
+
if state:
|
|
125
|
+
stmt = stmt.where(Ticket.state == state)
|
|
126
|
+
|
|
127
|
+
if query:
|
|
128
|
+
# Simple text search in title and description
|
|
129
|
+
search_term = f"%{query.lower()}%"
|
|
130
|
+
stmt = stmt.where(
|
|
131
|
+
(Ticket.title.ilike(search_term))
|
|
132
|
+
| (Ticket.description.ilike(search_term))
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Execute query
|
|
136
|
+
result = await db.execute(stmt)
|
|
137
|
+
tickets = result.scalars().all()
|
|
138
|
+
|
|
139
|
+
# Convert to JSON-serializable format
|
|
140
|
+
tickets_data = [
|
|
141
|
+
{
|
|
142
|
+
"id": t.id,
|
|
143
|
+
"title": t.title,
|
|
144
|
+
"description": t.description[:200]
|
|
145
|
+
if t.description
|
|
146
|
+
else None, # Cap at 200 chars
|
|
147
|
+
"state": t.state,
|
|
148
|
+
"priority": t.priority,
|
|
149
|
+
"blocked_by_ticket_id": t.blocked_by_ticket_id,
|
|
150
|
+
}
|
|
151
|
+
for t in tickets
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
return json.dumps(
|
|
155
|
+
{
|
|
156
|
+
"total": len(tickets_data),
|
|
157
|
+
"tickets": tickets_data,
|
|
158
|
+
},
|
|
159
|
+
indent=2,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
except Exception as e:
|
|
163
|
+
return json.dumps(
|
|
164
|
+
{
|
|
165
|
+
"error": str(e),
|
|
166
|
+
"total": 0,
|
|
167
|
+
"tickets": [],
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@tool
|
|
173
|
+
async def get_goal_context(db: AsyncSession, goal_id: str) -> str:
|
|
174
|
+
"""Get goal details and statistics.
|
|
175
|
+
|
|
176
|
+
This is a DETERMINISTIC tool (0 LLM calls). It retrieves the goal
|
|
177
|
+
and counts existing tickets by state.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
db: Database session
|
|
181
|
+
goal_id: Goal ID to retrieve
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
JSON string with goal context:
|
|
185
|
+
{
|
|
186
|
+
"id": "goal-123",
|
|
187
|
+
"title": "Add authentication system",
|
|
188
|
+
"description": "Implement OAuth2...",
|
|
189
|
+
"status": "active",
|
|
190
|
+
"ticket_counts": {
|
|
191
|
+
"proposed": 2,
|
|
192
|
+
"planned": 3,
|
|
193
|
+
"executing": 1,
|
|
194
|
+
"done": 5,
|
|
195
|
+
"total": 11
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
"""
|
|
199
|
+
try:
|
|
200
|
+
# Get goal
|
|
201
|
+
goal = await db.get(Goal, goal_id)
|
|
202
|
+
if not goal:
|
|
203
|
+
return json.dumps(
|
|
204
|
+
{
|
|
205
|
+
"error": f"Goal {goal_id} not found",
|
|
206
|
+
"id": goal_id,
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Count tickets by state
|
|
211
|
+
stmt = select(Ticket).where(Ticket.goal_id == goal_id)
|
|
212
|
+
result = await db.execute(stmt)
|
|
213
|
+
tickets = result.scalars().all()
|
|
214
|
+
|
|
215
|
+
ticket_counts = {}
|
|
216
|
+
for ticket in tickets:
|
|
217
|
+
state = ticket.state
|
|
218
|
+
ticket_counts[state] = ticket_counts.get(state, 0) + 1
|
|
219
|
+
|
|
220
|
+
# Build result
|
|
221
|
+
result_data = {
|
|
222
|
+
"id": goal.id,
|
|
223
|
+
"title": goal.title,
|
|
224
|
+
"description": goal.description[:500]
|
|
225
|
+
if goal.description
|
|
226
|
+
else None, # Cap at 500 chars
|
|
227
|
+
"created_at": goal.created_at.isoformat() if goal.created_at else None,
|
|
228
|
+
"ticket_counts": {
|
|
229
|
+
**ticket_counts,
|
|
230
|
+
"total": len(tickets),
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return json.dumps(result_data, indent=2)
|
|
235
|
+
|
|
236
|
+
except Exception as e:
|
|
237
|
+
return json.dumps(
|
|
238
|
+
{
|
|
239
|
+
"error": str(e),
|
|
240
|
+
"id": goal_id,
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@tool
|
|
246
|
+
async def analyze_ticket_changes(
|
|
247
|
+
db: AsyncSession,
|
|
248
|
+
ticket_id: str,
|
|
249
|
+
) -> str:
|
|
250
|
+
"""Analyze what changed in a completed ticket (deterministic, 0 LLM calls).
|
|
251
|
+
|
|
252
|
+
This tool parses git diffs and evidence to understand changes WITHOUT
|
|
253
|
+
calling an LLM. It extracts file counts, line changes, and verification
|
|
254
|
+
status using deterministic text parsing.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
db: Database session
|
|
258
|
+
ticket_id: Ticket ID to analyze
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
JSON string with change analysis:
|
|
262
|
+
{
|
|
263
|
+
"ticket_id": "abc-123",
|
|
264
|
+
"ticket_title": "Add authentication",
|
|
265
|
+
"state": "done",
|
|
266
|
+
"files_changed": ["backend/app/auth.py", "backend/app/models.py"],
|
|
267
|
+
"file_count": 2,
|
|
268
|
+
"lines_added": 150,
|
|
269
|
+
"lines_deleted": 20,
|
|
270
|
+
"verification_passed": true,
|
|
271
|
+
"has_revision": true
|
|
272
|
+
}
|
|
273
|
+
"""
|
|
274
|
+
try:
|
|
275
|
+
from sqlalchemy import select
|
|
276
|
+
from sqlalchemy.orm import selectinload
|
|
277
|
+
|
|
278
|
+
from app.models.ticket import Ticket
|
|
279
|
+
|
|
280
|
+
# Get ticket with revision
|
|
281
|
+
stmt = (
|
|
282
|
+
select(Ticket)
|
|
283
|
+
.where(Ticket.id == ticket_id)
|
|
284
|
+
.options(selectinload(Ticket.revisions))
|
|
285
|
+
)
|
|
286
|
+
result = await db.execute(stmt)
|
|
287
|
+
ticket = result.scalar_one_or_none()
|
|
288
|
+
|
|
289
|
+
if not ticket:
|
|
290
|
+
return json.dumps(
|
|
291
|
+
{
|
|
292
|
+
"error": f"Ticket {ticket_id} not found",
|
|
293
|
+
"ticket_id": ticket_id,
|
|
294
|
+
}
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# Get latest revision
|
|
298
|
+
revisions = sorted(ticket.revisions, key=lambda r: r.number, reverse=True)
|
|
299
|
+
latest_revision = revisions[0] if revisions else None
|
|
300
|
+
|
|
301
|
+
if not latest_revision:
|
|
302
|
+
return json.dumps(
|
|
303
|
+
{
|
|
304
|
+
"ticket_id": ticket_id,
|
|
305
|
+
"ticket_title": ticket.title,
|
|
306
|
+
"state": ticket.state,
|
|
307
|
+
"files_changed": [],
|
|
308
|
+
"file_count": 0,
|
|
309
|
+
"lines_added": 0,
|
|
310
|
+
"lines_deleted": 0,
|
|
311
|
+
"verification_passed": False,
|
|
312
|
+
"has_revision": False,
|
|
313
|
+
}
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# Load diff stat from evidence (deterministic)
|
|
317
|
+
files_changed = []
|
|
318
|
+
lines_added = 0
|
|
319
|
+
lines_deleted = 0
|
|
320
|
+
|
|
321
|
+
if latest_revision.diff_stat_evidence_id:
|
|
322
|
+
from app.models.evidence import Evidence
|
|
323
|
+
|
|
324
|
+
evidence = await db.get(Evidence, latest_revision.diff_stat_evidence_id)
|
|
325
|
+
diff_stat_content = None
|
|
326
|
+
if evidence and evidence.stdout_path:
|
|
327
|
+
try:
|
|
328
|
+
from pathlib import Path
|
|
329
|
+
|
|
330
|
+
diff_stat_content = Path(evidence.stdout_path).read_text()
|
|
331
|
+
except Exception:
|
|
332
|
+
pass
|
|
333
|
+
|
|
334
|
+
if diff_stat_content:
|
|
335
|
+
# Parse diff stat format: "file.py | 10 +++++-----"
|
|
336
|
+
for line in diff_stat_content.split("\n"):
|
|
337
|
+
if "|" in line:
|
|
338
|
+
file_part = line.split("|")[0].strip()
|
|
339
|
+
if file_part:
|
|
340
|
+
files_changed.append(file_part)
|
|
341
|
+
|
|
342
|
+
plus_count = line.count("+")
|
|
343
|
+
minus_count = line.count("-")
|
|
344
|
+
lines_added += plus_count
|
|
345
|
+
lines_deleted += minus_count
|
|
346
|
+
|
|
347
|
+
# Check verification status
|
|
348
|
+
verification_passed = latest_revision.status == "approved"
|
|
349
|
+
|
|
350
|
+
result_data = {
|
|
351
|
+
"ticket_id": ticket_id,
|
|
352
|
+
"ticket_title": ticket.title,
|
|
353
|
+
"state": ticket.state,
|
|
354
|
+
"files_changed": files_changed[:20], # Cap at 20 for prompt size
|
|
355
|
+
"file_count": len(files_changed),
|
|
356
|
+
"lines_added": lines_added,
|
|
357
|
+
"lines_deleted": lines_deleted,
|
|
358
|
+
"verification_passed": verification_passed,
|
|
359
|
+
"has_revision": True,
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return json.dumps(result_data, indent=2)
|
|
363
|
+
|
|
364
|
+
except Exception as e:
|
|
365
|
+
return json.dumps(
|
|
366
|
+
{
|
|
367
|
+
"error": str(e),
|
|
368
|
+
"ticket_id": ticket_id,
|
|
369
|
+
}
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# Export tools for easy import
|
|
374
|
+
__all__ = [
|
|
375
|
+
"analyze_codebase",
|
|
376
|
+
"search_tickets",
|
|
377
|
+
"get_goal_context",
|
|
378
|
+
"analyze_ticket_changes",
|
|
379
|
+
]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Authentication service: password hashing, JWT tokens, user CRUD."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
|
|
8
|
+
import bcrypt
|
|
9
|
+
from jose import JWTError, jwt
|
|
10
|
+
from sqlalchemy import select
|
|
11
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
12
|
+
|
|
13
|
+
from app.models.user import User
|
|
14
|
+
|
|
15
|
+
_logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# Configuration
|
|
18
|
+
_DEFAULT_SECRET = "draft-dev-secret-change-in-production"
|
|
19
|
+
SECRET_KEY = os.getenv("AUTH_SECRET_KEY", _DEFAULT_SECRET)
|
|
20
|
+
if SECRET_KEY == _DEFAULT_SECRET:
|
|
21
|
+
_logger.warning(
|
|
22
|
+
"AUTH_SECRET_KEY is not set — using insecure default. "
|
|
23
|
+
"Set AUTH_SECRET_KEY in your environment for production use."
|
|
24
|
+
)
|
|
25
|
+
ALGORITHM = "HS256"
|
|
26
|
+
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("AUTH_TOKEN_EXPIRE_MINUTES", "1440")) # 24h
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def hash_password(password: str) -> str:
|
|
30
|
+
"""Hash a plaintext password using bcrypt."""
|
|
31
|
+
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def verify_password(plain: str, hashed: str) -> bool:
|
|
35
|
+
"""Verify a plaintext password against a bcrypt hash."""
|
|
36
|
+
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def create_access_token(user_id: str, email: str) -> str:
|
|
40
|
+
"""Create a JWT access token for a user."""
|
|
41
|
+
expire = datetime.now(UTC) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
42
|
+
payload = {
|
|
43
|
+
"sub": user_id,
|
|
44
|
+
"email": email,
|
|
45
|
+
"exp": expire,
|
|
46
|
+
"iat": datetime.now(UTC),
|
|
47
|
+
}
|
|
48
|
+
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def decode_access_token(token: str) -> dict | None:
|
|
52
|
+
"""Decode and validate a JWT token. Returns payload or None."""
|
|
53
|
+
try:
|
|
54
|
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
55
|
+
if payload.get("sub") is None:
|
|
56
|
+
return None
|
|
57
|
+
return payload
|
|
58
|
+
except JWTError:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
|
|
63
|
+
"""Look up a user by email."""
|
|
64
|
+
result = await db.execute(select(User).where(User.email == email))
|
|
65
|
+
return result.scalar_one_or_none()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def get_user_by_id(db: AsyncSession, user_id: str) -> User | None:
|
|
69
|
+
"""Look up a user by ID."""
|
|
70
|
+
result = await db.execute(select(User).where(User.id == user_id))
|
|
71
|
+
return result.scalar_one_or_none()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def create_user(
|
|
75
|
+
db: AsyncSession, email: str, password: str, display_name: str
|
|
76
|
+
) -> User:
|
|
77
|
+
"""Create a new user account. Caller must handle duplicate email checks."""
|
|
78
|
+
user = User(
|
|
79
|
+
id=str(uuid.uuid4()),
|
|
80
|
+
email=email.lower().strip(),
|
|
81
|
+
display_name=display_name.strip(),
|
|
82
|
+
hashed_password=hash_password(password),
|
|
83
|
+
)
|
|
84
|
+
db.add(user)
|
|
85
|
+
await db.flush()
|
|
86
|
+
return user
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def authenticate_user(db: AsyncSession, email: str, password: str) -> User | None:
|
|
90
|
+
"""Authenticate a user by email and password. Returns User or None."""
|
|
91
|
+
user = await get_user_by_email(db, email.lower().strip())
|
|
92
|
+
if user is None:
|
|
93
|
+
return None
|
|
94
|
+
if not user.is_active:
|
|
95
|
+
return None
|
|
96
|
+
if not verify_password(password, user.hashed_password):
|
|
97
|
+
return None
|
|
98
|
+
return user
|