codeframe-ai 0.9.0__py3-none-any.whl
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.
- codeframe/__init__.py +11 -0
- codeframe/__main__.py +20 -0
- codeframe/adapters/__init__.py +5 -0
- codeframe/adapters/e2b/__init__.py +13 -0
- codeframe/adapters/e2b/adapter.py +342 -0
- codeframe/adapters/e2b/budget.py +71 -0
- codeframe/adapters/e2b/credential_scanner.py +134 -0
- codeframe/adapters/llm/__init__.py +92 -0
- codeframe/adapters/llm/anthropic.py +414 -0
- codeframe/adapters/llm/base.py +444 -0
- codeframe/adapters/llm/mock.py +281 -0
- codeframe/adapters/llm/openai.py +483 -0
- codeframe/agents/__init__.py +8 -0
- codeframe/agents/dependency_resolver.py +714 -0
- codeframe/auth/__init__.py +16 -0
- codeframe/auth/api_key_router.py +238 -0
- codeframe/auth/api_keys.py +156 -0
- codeframe/auth/dependencies.py +358 -0
- codeframe/auth/manager.py +178 -0
- codeframe/auth/models.py +30 -0
- codeframe/auth/router.py +93 -0
- codeframe/auth/schemas.py +15 -0
- codeframe/auth/scopes.py +53 -0
- codeframe/cli/__init__.py +12 -0
- codeframe/cli/__main__.py +20 -0
- codeframe/cli/api_client.py +275 -0
- codeframe/cli/app.py +5688 -0
- codeframe/cli/auth.py +122 -0
- codeframe/cli/auth_commands.py +958 -0
- codeframe/cli/commands/__init__.py +5 -0
- codeframe/cli/config_commands.py +79 -0
- codeframe/cli/dashboard_commands.py +67 -0
- codeframe/cli/engines_commands.py +205 -0
- codeframe/cli/env_commands.py +409 -0
- codeframe/cli/helpers.py +56 -0
- codeframe/cli/hooks_commands.py +208 -0
- codeframe/cli/import_commands.py +129 -0
- codeframe/cli/pr_commands.py +549 -0
- codeframe/cli/proof_commands.py +415 -0
- codeframe/cli/stats_commands.py +311 -0
- codeframe/cli/telemetry_runtime.py +153 -0
- codeframe/cli/validators.py +123 -0
- codeframe/config/rate_limits.py +165 -0
- codeframe/core/__init__.py +15 -0
- codeframe/core/adapters/__init__.py +43 -0
- codeframe/core/adapters/agent_adapter.py +114 -0
- codeframe/core/adapters/builtin.py +326 -0
- codeframe/core/adapters/claude_code.py +62 -0
- codeframe/core/adapters/codex.py +393 -0
- codeframe/core/adapters/git_utils.py +40 -0
- codeframe/core/adapters/kilocode.py +126 -0
- codeframe/core/adapters/opencode.py +48 -0
- codeframe/core/adapters/streaming_chat.py +483 -0
- codeframe/core/adapters/subprocess_adapter.py +213 -0
- codeframe/core/adapters/verification_wrapper.py +269 -0
- codeframe/core/agent.py +2183 -0
- codeframe/core/agents_config.py +569 -0
- codeframe/core/api_key_service.py +211 -0
- codeframe/core/artifacts.py +428 -0
- codeframe/core/blocker_detection.py +218 -0
- codeframe/core/blockers.py +433 -0
- codeframe/core/checkpoints.py +481 -0
- codeframe/core/conductor.py +2255 -0
- codeframe/core/config.py +827 -0
- codeframe/core/config_watcher.py +268 -0
- codeframe/core/context.py +542 -0
- codeframe/core/context_packager.py +234 -0
- codeframe/core/credentials.py +735 -0
- codeframe/core/dependency_analyzer.py +229 -0
- codeframe/core/dependency_graph.py +290 -0
- codeframe/core/diagnostic_agent.py +712 -0
- codeframe/core/diagnostics.py +616 -0
- codeframe/core/editor.py +556 -0
- codeframe/core/engine_registry.py +256 -0
- codeframe/core/engine_stats.py +231 -0
- codeframe/core/environment.py +697 -0
- codeframe/core/events.py +375 -0
- codeframe/core/executor.py +1005 -0
- codeframe/core/fix_tracker.py +480 -0
- codeframe/core/gates.py +1322 -0
- codeframe/core/git.py +477 -0
- codeframe/core/github_connect_service.py +178 -0
- codeframe/core/github_integration_config.py +118 -0
- codeframe/core/github_issues_service.py +449 -0
- codeframe/core/hooks.py +184 -0
- codeframe/core/importers/__init__.py +1 -0
- codeframe/core/importers/ralph.py +540 -0
- codeframe/core/installer.py +650 -0
- codeframe/core/models.py +1026 -0
- codeframe/core/notifications_config.py +183 -0
- codeframe/core/planner.py +437 -0
- codeframe/core/prd.py +670 -0
- codeframe/core/prd_discovery.py +1118 -0
- codeframe/core/prd_stress_test.py +499 -0
- codeframe/core/progress.py +126 -0
- codeframe/core/proof/__init__.py +34 -0
- codeframe/core/proof/capture.py +79 -0
- codeframe/core/proof/evidence.py +56 -0
- codeframe/core/proof/ledger.py +574 -0
- codeframe/core/proof/models.py +162 -0
- codeframe/core/proof/obligations.py +103 -0
- codeframe/core/proof/runner.py +233 -0
- codeframe/core/proof/scope.py +81 -0
- codeframe/core/proof/stubs.py +156 -0
- codeframe/core/quick_fixes.py +558 -0
- codeframe/core/react_agent.py +1650 -0
- codeframe/core/reconciliation.py +183 -0
- codeframe/core/replay.py +788 -0
- codeframe/core/review.py +285 -0
- codeframe/core/runtime.py +1134 -0
- codeframe/core/sandbox/__init__.py +27 -0
- codeframe/core/sandbox/context.py +98 -0
- codeframe/core/sandbox/worktree.py +20 -0
- codeframe/core/schedule.py +396 -0
- codeframe/core/stall_detector.py +71 -0
- codeframe/core/stall_monitor.py +134 -0
- codeframe/core/state_machine.py +121 -0
- codeframe/core/streaming.py +502 -0
- codeframe/core/task_tree.py +400 -0
- codeframe/core/tasks.py +1022 -0
- codeframe/core/telemetry.py +232 -0
- codeframe/core/templates.py +221 -0
- codeframe/core/tools.py +942 -0
- codeframe/core/workspace.py +887 -0
- codeframe/core/worktrees.py +276 -0
- codeframe/git/__init__.py +5 -0
- codeframe/git/github_integration.py +505 -0
- codeframe/lib/__init__.py +0 -0
- codeframe/lib/audit_logger.py +248 -0
- codeframe/lib/metrics_tracker.py +800 -0
- codeframe/lib/quality/__init__.py +7 -0
- codeframe/lib/quality/complexity_analyzer.py +316 -0
- codeframe/lib/quality/owasp_patterns.py +284 -0
- codeframe/lib/quality/security_scanner.py +250 -0
- codeframe/lib/rate_limiter.py +312 -0
- codeframe/notifications/__init__.py +0 -0
- codeframe/notifications/webhook.py +380 -0
- codeframe/planning/__init__.py +30 -0
- codeframe/planning/issue_generator.py +219 -0
- codeframe/planning/prd_template_functions.py +137 -0
- codeframe/planning/prd_templates.py +975 -0
- codeframe/planning/task_scheduler.py +511 -0
- codeframe/planning/task_templates.py +533 -0
- codeframe/platform_store/__init__.py +5 -0
- codeframe/platform_store/database.py +277 -0
- codeframe/platform_store/repositories/__init__.py +24 -0
- codeframe/platform_store/repositories/api_key_repository.py +245 -0
- codeframe/platform_store/repositories/audit_repository.py +67 -0
- codeframe/platform_store/repositories/base.py +295 -0
- codeframe/platform_store/repositories/interactive_sessions.py +165 -0
- codeframe/platform_store/repositories/token_repository.py +598 -0
- codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
- codeframe/platform_store/schema_manager.py +321 -0
- codeframe/templates/AGENTS.md.default +94 -0
- codeframe/tui/__init__.py +5 -0
- codeframe/tui/app.py +256 -0
- codeframe/tui/data_service.py +103 -0
- codeframe/ui/__init__.py +0 -0
- codeframe/ui/dependencies.py +103 -0
- codeframe/ui/models.py +999 -0
- codeframe/ui/response_models.py +201 -0
- codeframe/ui/routers/__init__.py +5 -0
- codeframe/ui/routers/_helpers.py +29 -0
- codeframe/ui/routers/batches_v2.py +315 -0
- codeframe/ui/routers/blockers_v2.py +320 -0
- codeframe/ui/routers/checkpoints_v2.py +310 -0
- codeframe/ui/routers/costs_v2.py +322 -0
- codeframe/ui/routers/diagnose_v2.py +225 -0
- codeframe/ui/routers/discovery_v2.py +417 -0
- codeframe/ui/routers/environment_v2.py +284 -0
- codeframe/ui/routers/events_v2.py +75 -0
- codeframe/ui/routers/gates_v2.py +166 -0
- codeframe/ui/routers/git_v2.py +284 -0
- codeframe/ui/routers/github_integrations_v2.py +532 -0
- codeframe/ui/routers/interactive_sessions_v2.py +238 -0
- codeframe/ui/routers/pr_v2.py +709 -0
- codeframe/ui/routers/prd_v2.py +695 -0
- codeframe/ui/routers/proof_v2.py +755 -0
- codeframe/ui/routers/review_v2.py +360 -0
- codeframe/ui/routers/schedule_v2.py +214 -0
- codeframe/ui/routers/session_chat_ws.py +354 -0
- codeframe/ui/routers/settings_v2.py +562 -0
- codeframe/ui/routers/streaming_v2.py +155 -0
- codeframe/ui/routers/tasks_v2.py +1098 -0
- codeframe/ui/routers/templates_v2.py +232 -0
- codeframe/ui/routers/terminal_ws.py +267 -0
- codeframe/ui/routers/workspace_v2.py +527 -0
- codeframe/ui/server.py +568 -0
- codeframe/ui/shared.py +241 -0
- codeframe/workspace/__init__.py +5 -0
- codeframe/workspace/manager.py +249 -0
- codeframe_ai-0.9.0.dist-info/METADATA +517 -0
- codeframe_ai-0.9.0.dist-info/RECORD +197 -0
- codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
- codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
- codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
- codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Webhook notification service.
|
|
2
|
+
|
|
3
|
+
Originally built for SYNC blocker alerts (049-human-in-loop, Phase 7), now also
|
|
4
|
+
powers outbound event webhooks for batch completion / blocker creation / PR
|
|
5
|
+
merge (issue #560).
|
|
6
|
+
|
|
7
|
+
Delivery is fire-and-forget with a configurable timeout — failures are logged
|
|
8
|
+
but never break the triggering operation.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
import threading
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
import aiohttp
|
|
19
|
+
|
|
20
|
+
from codeframe.core.models import BlockerType
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class WebhookSendResult:
|
|
27
|
+
"""Result of a single ``send_event`` call.
|
|
28
|
+
|
|
29
|
+
``ok`` mirrors HTTP 2xx semantics. ``status_code`` is ``None`` when the
|
|
30
|
+
request never completed (timeout, DNS failure, connection refused, …) —
|
|
31
|
+
the ``error`` field carries a short human-readable reason in that case.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
ok: bool
|
|
35
|
+
status_code: Optional[int]
|
|
36
|
+
error: Optional[str] = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _utc_iso_now() -> str:
|
|
40
|
+
"""ISO-8601 UTC timestamp for webhook payloads (``Z`` suffix).
|
|
41
|
+
|
|
42
|
+
Slack/Discord/Zapier-style consumers expect ``Z``, not ``+00:00``.
|
|
43
|
+
Drops sub-second precision for the same reason — consumers vary widely
|
|
44
|
+
in fractional-second handling.
|
|
45
|
+
"""
|
|
46
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def format_batch_payload(batch_id: str, task_count: int) -> dict:
|
|
50
|
+
# ``status`` is always "completed" today (the dispatcher gates on
|
|
51
|
+
# BATCH_COMPLETED only) but is included so consumers can self-document
|
|
52
|
+
# and so a future PARTIAL/FAILED extension is forward-compatible.
|
|
53
|
+
return {
|
|
54
|
+
"event": "batch.completed",
|
|
55
|
+
"batch_id": batch_id,
|
|
56
|
+
"task_count": task_count,
|
|
57
|
+
"status": "completed",
|
|
58
|
+
"timestamp": _utc_iso_now(),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def format_blocker_payload(blocker_id: str, task_id: Optional[str]) -> dict:
|
|
63
|
+
return {
|
|
64
|
+
"event": "blocker.created",
|
|
65
|
+
"blocker_id": blocker_id,
|
|
66
|
+
"task_id": task_id,
|
|
67
|
+
"timestamp": _utc_iso_now(),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def format_pr_payload(pr_number: int, pr_url: Optional[str] = None) -> dict:
|
|
72
|
+
# ``pr_number`` is always present so consumers can branch on it.
|
|
73
|
+
# ``pr_url`` is the canonical github.com URL when GITHUB_REPO is set,
|
|
74
|
+
# ``None`` otherwise (an unparseable sentinel like ``"pr#42"`` was
|
|
75
|
+
# actively confusing for downstream handlers).
|
|
76
|
+
return {
|
|
77
|
+
"event": "pr.merged",
|
|
78
|
+
"pr_number": pr_number,
|
|
79
|
+
"pr_url": pr_url,
|
|
80
|
+
"timestamp": _utc_iso_now(),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def format_test_payload() -> dict:
|
|
85
|
+
return {"event": "test", "timestamp": _utc_iso_now()}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class WebhookNotificationService:
|
|
89
|
+
"""Async outbound webhook service.
|
|
90
|
+
|
|
91
|
+
Originally built for SYNC blocker alerts (``send_blocker_notification``);
|
|
92
|
+
now also powers the issue #560 outbound event webhooks (batch / blocker /
|
|
93
|
+
PR / test) via ``send_event``. Delivery is fire-and-forget with a
|
|
94
|
+
configurable timeout — failures are logged but never break the triggering
|
|
95
|
+
operation.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
webhook_url: Optional[str] = None,
|
|
101
|
+
timeout: int = 5,
|
|
102
|
+
dashboard_base_url: str = "http://localhost:3000",
|
|
103
|
+
):
|
|
104
|
+
"""Initialize webhook notification service.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
webhook_url: Target webhook endpoint URL (e.g., Zapier, webhook.site)
|
|
108
|
+
timeout: HTTP request timeout in seconds (default: 5)
|
|
109
|
+
dashboard_base_url: Base URL for dashboard links (default: http://localhost:3000)
|
|
110
|
+
"""
|
|
111
|
+
self.webhook_url = webhook_url
|
|
112
|
+
self.timeout = timeout
|
|
113
|
+
self.dashboard_base_url = dashboard_base_url
|
|
114
|
+
|
|
115
|
+
def is_enabled(self) -> bool:
|
|
116
|
+
"""Check if webhook notifications are enabled.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
True if webhook_url is configured, False otherwise
|
|
120
|
+
"""
|
|
121
|
+
return self.webhook_url is not None and self.webhook_url.strip() != ""
|
|
122
|
+
|
|
123
|
+
def format_payload(
|
|
124
|
+
self,
|
|
125
|
+
blocker_id: int,
|
|
126
|
+
question: str,
|
|
127
|
+
agent_id: str,
|
|
128
|
+
task_id: int,
|
|
129
|
+
blocker_type: BlockerType,
|
|
130
|
+
created_at: datetime,
|
|
131
|
+
) -> dict:
|
|
132
|
+
"""Format webhook payload with blocker details.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
blocker_id: Blocker database ID
|
|
136
|
+
question: Blocker question text
|
|
137
|
+
agent_id: Agent that created the blocker
|
|
138
|
+
task_id: Associated task ID
|
|
139
|
+
blocker_type: SYNC or ASYNC
|
|
140
|
+
created_at: Blocker creation timestamp
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Dictionary payload ready for JSON serialization
|
|
144
|
+
"""
|
|
145
|
+
dashboard_url = f"{self.dashboard_base_url}/#blocker-{blocker_id}"
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
"blocker_id": blocker_id,
|
|
149
|
+
"question": question,
|
|
150
|
+
"agent_id": agent_id,
|
|
151
|
+
"task_id": task_id,
|
|
152
|
+
"type": blocker_type.value,
|
|
153
|
+
"created_at": created_at.isoformat(),
|
|
154
|
+
"dashboard_url": dashboard_url,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async def send_blocker_notification(
|
|
158
|
+
self,
|
|
159
|
+
blocker_id: int,
|
|
160
|
+
question: str,
|
|
161
|
+
agent_id: str,
|
|
162
|
+
task_id: int,
|
|
163
|
+
blocker_type: BlockerType,
|
|
164
|
+
created_at: datetime,
|
|
165
|
+
) -> bool:
|
|
166
|
+
"""Send async webhook notification for a blocker.
|
|
167
|
+
|
|
168
|
+
Fire-and-forget delivery with timeout. Logs errors but doesn't block execution.
|
|
169
|
+
Only sends notifications for SYNC blockers.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
blocker_id: Blocker database ID
|
|
173
|
+
question: Blocker question text
|
|
174
|
+
agent_id: Agent that created the blocker
|
|
175
|
+
task_id: Associated task ID
|
|
176
|
+
blocker_type: SYNC or ASYNC
|
|
177
|
+
created_at: Blocker creation timestamp
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
True if notification sent successfully, False on failure
|
|
181
|
+
"""
|
|
182
|
+
# Only send notifications for SYNC blockers
|
|
183
|
+
if blocker_type != BlockerType.SYNC:
|
|
184
|
+
logger.debug(f"Skipping webhook notification for ASYNC blocker {blocker_id}")
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
# Check if webhooks are enabled
|
|
188
|
+
if not self.is_enabled():
|
|
189
|
+
logger.debug(f"Webhook notifications disabled, skipping blocker {blocker_id}")
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
# Format payload
|
|
193
|
+
payload = self.format_payload(
|
|
194
|
+
blocker_id=blocker_id,
|
|
195
|
+
question=question,
|
|
196
|
+
agent_id=agent_id,
|
|
197
|
+
task_id=task_id,
|
|
198
|
+
blocker_type=blocker_type,
|
|
199
|
+
created_at=created_at,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
# Send HTTP POST with timeout
|
|
204
|
+
async with aiohttp.ClientSession() as session:
|
|
205
|
+
async with session.post(
|
|
206
|
+
self.webhook_url,
|
|
207
|
+
json=payload,
|
|
208
|
+
timeout=aiohttp.ClientTimeout(total=self.timeout),
|
|
209
|
+
) as response:
|
|
210
|
+
response.raise_for_status()
|
|
211
|
+
|
|
212
|
+
logger.info(
|
|
213
|
+
f"Webhook notification sent for blocker {blocker_id} "
|
|
214
|
+
f"(status: {response.status})"
|
|
215
|
+
)
|
|
216
|
+
return True
|
|
217
|
+
|
|
218
|
+
except asyncio.TimeoutError:
|
|
219
|
+
logger.error(
|
|
220
|
+
f"Webhook notification timeout for blocker {blocker_id} "
|
|
221
|
+
f"(exceeded {self.timeout}s)"
|
|
222
|
+
)
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
except aiohttp.ClientError as e:
|
|
226
|
+
logger.error(f"Webhook notification failed for blocker {blocker_id}: {e}")
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
except Exception as e:
|
|
230
|
+
logger.error(
|
|
231
|
+
f"Unexpected error sending webhook for blocker {blocker_id}: {e}", exc_info=True
|
|
232
|
+
)
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
async def send_event(
|
|
236
|
+
self, payload: dict, url: Optional[str] = None
|
|
237
|
+
) -> WebhookSendResult:
|
|
238
|
+
"""Generic webhook POST for outbound event notifications (issue #560).
|
|
239
|
+
|
|
240
|
+
Unlike ``send_blocker_notification``, this method:
|
|
241
|
+
|
|
242
|
+
* Accepts an arbitrary JSON payload (the caller composes the event).
|
|
243
|
+
* Returns rich status information so the Settings ``Test`` endpoint
|
|
244
|
+
can surface the HTTP status code or error to the user.
|
|
245
|
+
* Accepts an optional ``url`` override so the same service instance
|
|
246
|
+
can dispatch to a freshly-configured URL without rebuilding state.
|
|
247
|
+
|
|
248
|
+
Failures (timeout, network, non-2xx) are logged but never raised —
|
|
249
|
+
the caller can react via the returned ``WebhookSendResult``.
|
|
250
|
+
"""
|
|
251
|
+
target_url = url or self.webhook_url
|
|
252
|
+
if not target_url or not target_url.strip():
|
|
253
|
+
return WebhookSendResult(
|
|
254
|
+
ok=False, status_code=None, error="No webhook URL configured"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
async with aiohttp.ClientSession() as session:
|
|
259
|
+
async with session.post(
|
|
260
|
+
target_url,
|
|
261
|
+
json=payload,
|
|
262
|
+
timeout=aiohttp.ClientTimeout(total=self.timeout),
|
|
263
|
+
) as response:
|
|
264
|
+
ok = 200 <= response.status < 300
|
|
265
|
+
if not ok:
|
|
266
|
+
logger.warning(
|
|
267
|
+
"Webhook returned non-2xx status %s for event %s",
|
|
268
|
+
response.status,
|
|
269
|
+
payload.get("event"),
|
|
270
|
+
)
|
|
271
|
+
return WebhookSendResult(ok=ok, status_code=response.status)
|
|
272
|
+
except asyncio.TimeoutError:
|
|
273
|
+
logger.error(
|
|
274
|
+
"Webhook timeout for event %s (exceeded %ss)",
|
|
275
|
+
payload.get("event"),
|
|
276
|
+
self.timeout,
|
|
277
|
+
)
|
|
278
|
+
return WebhookSendResult(
|
|
279
|
+
ok=False, status_code=None, error=f"Timeout after {self.timeout}s"
|
|
280
|
+
)
|
|
281
|
+
except aiohttp.ClientError as e:
|
|
282
|
+
logger.error("Webhook ClientError for event %s: %s", payload.get("event"), e)
|
|
283
|
+
return WebhookSendResult(ok=False, status_code=None, error=str(e))
|
|
284
|
+
except Exception as e:
|
|
285
|
+
logger.error(
|
|
286
|
+
"Unexpected webhook error for event %s: %s",
|
|
287
|
+
payload.get("event"),
|
|
288
|
+
e,
|
|
289
|
+
exc_info=True,
|
|
290
|
+
)
|
|
291
|
+
return WebhookSendResult(ok=False, status_code=None, error=str(e))
|
|
292
|
+
|
|
293
|
+
def send_event_background(self, payload: dict, url: Optional[str] = None) -> None:
|
|
294
|
+
"""Fire-and-forget wrapper for ``send_event``.
|
|
295
|
+
|
|
296
|
+
Works in both contexts:
|
|
297
|
+
|
|
298
|
+
* **Async** (FastAPI request handler): schedules the send on the
|
|
299
|
+
current event loop via ``loop.create_task`` and returns
|
|
300
|
+
immediately.
|
|
301
|
+
* **Sync** (CLI batch run, sync test): spawns a daemon thread that
|
|
302
|
+
runs the send in a fresh event loop. The thread is daemon so it
|
|
303
|
+
never blocks process exit; ``timeout`` still applies inside the
|
|
304
|
+
loop, so the thread lives at most ``self.timeout`` seconds.
|
|
305
|
+
|
|
306
|
+
Either way, the triggering operation never blocks on webhook
|
|
307
|
+
delivery and never sees an exception from this method.
|
|
308
|
+
"""
|
|
309
|
+
try:
|
|
310
|
+
loop = asyncio.get_running_loop()
|
|
311
|
+
except RuntimeError:
|
|
312
|
+
# No running loop — we're in sync context (CLI). Run the send
|
|
313
|
+
# in a daemon thread so we don't block the caller and so the
|
|
314
|
+
# process can exit cleanly even if the webhook hangs.
|
|
315
|
+
thread = threading.Thread(
|
|
316
|
+
target=self._run_send_event_sync,
|
|
317
|
+
args=(payload, url),
|
|
318
|
+
daemon=True,
|
|
319
|
+
name="webhook-send-event",
|
|
320
|
+
)
|
|
321
|
+
thread.start()
|
|
322
|
+
return
|
|
323
|
+
task = loop.create_task(self.send_event(payload, url=url))
|
|
324
|
+
# ``send_event`` already swallows all exceptions, but Python 3.11+
|
|
325
|
+
# warns ``Task exception was never retrieved`` if a task ends with
|
|
326
|
+
# an unhandled exception and nobody awaited / called .exception().
|
|
327
|
+
# Add a no-op callback so the result is always consumed.
|
|
328
|
+
task.add_done_callback(
|
|
329
|
+
lambda t: t.exception() if not t.cancelled() else None
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
def _run_send_event_sync(self, payload: dict, url: Optional[str]) -> None:
|
|
333
|
+
"""Run ``send_event`` to completion in a fresh event loop.
|
|
334
|
+
|
|
335
|
+
Used only by the sync branch of ``send_event_background`` — never
|
|
336
|
+
raises into the calling thread (the daemon thread is meant to die
|
|
337
|
+
quietly).
|
|
338
|
+
"""
|
|
339
|
+
try:
|
|
340
|
+
asyncio.run(self.send_event(payload, url=url))
|
|
341
|
+
except Exception:
|
|
342
|
+
logger.warning(
|
|
343
|
+
"Sync webhook dispatch failed for event %s",
|
|
344
|
+
payload.get("event"),
|
|
345
|
+
exc_info=True,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
def send_blocker_notification_background(
|
|
349
|
+
self,
|
|
350
|
+
blocker_id: int,
|
|
351
|
+
question: str,
|
|
352
|
+
agent_id: str,
|
|
353
|
+
task_id: int,
|
|
354
|
+
blocker_type: BlockerType,
|
|
355
|
+
created_at: datetime,
|
|
356
|
+
) -> None:
|
|
357
|
+
"""Fire-and-forget wrapper for send_blocker_notification.
|
|
358
|
+
|
|
359
|
+
Launches notification task in background without awaiting result.
|
|
360
|
+
Use this method to avoid blocking blocker creation.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
blocker_id: Blocker database ID
|
|
364
|
+
question: Blocker question text
|
|
365
|
+
agent_id: Agent that created the blocker
|
|
366
|
+
task_id: Associated task ID
|
|
367
|
+
blocker_type: SYNC or ASYNC
|
|
368
|
+
created_at: Blocker creation timestamp
|
|
369
|
+
"""
|
|
370
|
+
# Create background task
|
|
371
|
+
asyncio.create_task(
|
|
372
|
+
self.send_blocker_notification(
|
|
373
|
+
blocker_id=blocker_id,
|
|
374
|
+
question=question,
|
|
375
|
+
agent_id=agent_id,
|
|
376
|
+
task_id=task_id,
|
|
377
|
+
blocker_type=blocker_type,
|
|
378
|
+
created_at=created_at,
|
|
379
|
+
)
|
|
380
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Planning module for CodeFRAME.
|
|
2
|
+
|
|
3
|
+
This module handles PRD-to-Issue-to-Task decomposition:
|
|
4
|
+
- Issue generation from PRD features
|
|
5
|
+
- Task decomposition from issues
|
|
6
|
+
- Work breakdown planning
|
|
7
|
+
- PRD template system for customizable output formats
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from codeframe.planning.issue_generator import (
|
|
11
|
+
IssueGenerator,
|
|
12
|
+
parse_prd_features,
|
|
13
|
+
assign_priority,
|
|
14
|
+
)
|
|
15
|
+
from codeframe.planning.prd_templates import (
|
|
16
|
+
PrdTemplate,
|
|
17
|
+
PrdTemplateSection,
|
|
18
|
+
PrdTemplateManager,
|
|
19
|
+
BUILTIN_TEMPLATES,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"IssueGenerator",
|
|
24
|
+
"parse_prd_features",
|
|
25
|
+
"assign_priority",
|
|
26
|
+
"PrdTemplate",
|
|
27
|
+
"PrdTemplateSection",
|
|
28
|
+
"PrdTemplateManager",
|
|
29
|
+
"BUILTIN_TEMPLATES",
|
|
30
|
+
]
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Issue Generator - Extract high-level features from PRD and create Issues.
|
|
2
|
+
|
|
3
|
+
This module implements the hierarchical work breakdown for CodeFRAME:
|
|
4
|
+
PRD → Issues → Tasks
|
|
5
|
+
|
|
6
|
+
Issues are high-level work items (e.g., "2.1", "2.2") that can parallelize.
|
|
7
|
+
Each issue will later be decomposed into sequential tasks (e.g., "2.1.1", "2.1.2").
|
|
8
|
+
|
|
9
|
+
Algorithm:
|
|
10
|
+
1. Parse PRD markdown for "Features & Requirements" section
|
|
11
|
+
2. Each major feature (### header) becomes an Issue
|
|
12
|
+
3. Number issues sequentially: {sprint}.1, {sprint}.2, etc.
|
|
13
|
+
4. Extract title and description from feature text
|
|
14
|
+
5. Assign priority based on feature importance keywords
|
|
15
|
+
6. Set status='pending', workflow_step=0
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
import logging
|
|
20
|
+
from typing import List, Dict, Any
|
|
21
|
+
from codeframe.core.models import Issue, TaskStatus
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_prd_features(prd_content: str) -> List[Dict[str, Any]]:
|
|
28
|
+
"""Parse PRD markdown and extract features from Features & Requirements section.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
prd_content: Full PRD content as markdown string
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
List of feature dictionaries with 'title', 'description', and 'raw_text'
|
|
35
|
+
"""
|
|
36
|
+
if not prd_content or not prd_content.strip():
|
|
37
|
+
logger.warning("Empty PRD content provided")
|
|
38
|
+
return []
|
|
39
|
+
|
|
40
|
+
features = []
|
|
41
|
+
|
|
42
|
+
# Find the Features & Requirements section
|
|
43
|
+
# Look for ## Features & Requirements, then capture everything until next ## or end
|
|
44
|
+
features_section_pattern = r"##\s+Features\s*&\s*Requirements(.*?)(?=\n##[^#]|\Z)"
|
|
45
|
+
features_match = re.search(features_section_pattern, prd_content, re.IGNORECASE | re.DOTALL)
|
|
46
|
+
|
|
47
|
+
if not features_match:
|
|
48
|
+
logger.warning("No 'Features & Requirements' section found in PRD")
|
|
49
|
+
return []
|
|
50
|
+
|
|
51
|
+
features_section = features_match.group(1).strip()
|
|
52
|
+
|
|
53
|
+
# Extract individual features using ### headers (but not #### or deeper)
|
|
54
|
+
# Match ### followed by title, then capture everything until next ### or end of section
|
|
55
|
+
# Use negative lookahead to ensure we don't match #### or more
|
|
56
|
+
feature_pattern = r"###(?!#)\s+([^\n]+)\n(.*?)(?=\n###(?!#)|\Z)"
|
|
57
|
+
feature_matches = re.finditer(feature_pattern, features_section, re.DOTALL)
|
|
58
|
+
|
|
59
|
+
for match in feature_matches:
|
|
60
|
+
raw_title = match.group(1).strip()
|
|
61
|
+
description = match.group(2).strip()
|
|
62
|
+
|
|
63
|
+
# Remove priority keywords from title (Critical:, High:, etc.)
|
|
64
|
+
title = re.sub(r"^(Critical|High|Medium|Low)\s*:\s*", "", raw_title, flags=re.IGNORECASE)
|
|
65
|
+
|
|
66
|
+
features.append(
|
|
67
|
+
{
|
|
68
|
+
"title": title.strip(),
|
|
69
|
+
"description": description.strip(),
|
|
70
|
+
"raw_text": raw_title + "\n" + description, # Keep original for priority detection
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
logger.info(f"Extracted {len(features)} features from PRD")
|
|
75
|
+
return features
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def assign_priority(text: str) -> int:
|
|
79
|
+
"""Assign priority based on keywords in text.
|
|
80
|
+
|
|
81
|
+
Priority levels:
|
|
82
|
+
- 0: Critical
|
|
83
|
+
- 1: High
|
|
84
|
+
- 2: Medium (default)
|
|
85
|
+
- 3: Low
|
|
86
|
+
- 4: Nice-to-have
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
text: Feature text to analyze for priority keywords
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Priority level (0-4)
|
|
93
|
+
"""
|
|
94
|
+
text_lower = text.lower()
|
|
95
|
+
|
|
96
|
+
# Check for priority keywords (case-insensitive, first match wins)
|
|
97
|
+
if "critical" in text_lower or "urgent" in text_lower or "must have" in text_lower:
|
|
98
|
+
return 0
|
|
99
|
+
elif "high" in text_lower or "important" in text_lower:
|
|
100
|
+
return 1
|
|
101
|
+
elif "low" in text_lower or "optional" in text_lower:
|
|
102
|
+
return 3
|
|
103
|
+
elif "nice to have" in text_lower or "nice-to-have" in text_lower:
|
|
104
|
+
return 4
|
|
105
|
+
else:
|
|
106
|
+
# Default to medium priority
|
|
107
|
+
return 2
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class IssueGenerator:
|
|
111
|
+
"""Generator for creating Issues from PRD features."""
|
|
112
|
+
|
|
113
|
+
def __init__(self):
|
|
114
|
+
"""Initialize the issue generator."""
|
|
115
|
+
self.logger = logging.getLogger(__name__)
|
|
116
|
+
|
|
117
|
+
def generate_issues_from_prd(self, prd_content: str, sprint_number: int) -> List[Issue]:
|
|
118
|
+
"""Generate issues from PRD content.
|
|
119
|
+
|
|
120
|
+
Main entry point for issue generation. Parses PRD, creates issues,
|
|
121
|
+
validates them, and returns the list.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
prd_content: Full PRD markdown content
|
|
125
|
+
sprint_number: Sprint number for issue numbering (e.g., 2 for Sprint 2)
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
List of Issue objects with sequential numbering
|
|
129
|
+
"""
|
|
130
|
+
self.logger.info(f"Generating issues from PRD for sprint {sprint_number}")
|
|
131
|
+
|
|
132
|
+
# Parse features from PRD
|
|
133
|
+
features = parse_prd_features(prd_content)
|
|
134
|
+
|
|
135
|
+
if not features:
|
|
136
|
+
self.logger.warning("No features found in PRD, returning empty list")
|
|
137
|
+
return []
|
|
138
|
+
|
|
139
|
+
# Create issues from features
|
|
140
|
+
issues = self._create_issues(features, sprint_number)
|
|
141
|
+
|
|
142
|
+
# Validate all issues
|
|
143
|
+
for issue in issues:
|
|
144
|
+
self._validate_issue(issue)
|
|
145
|
+
|
|
146
|
+
self.logger.info(f"Generated {len(issues)} issues successfully")
|
|
147
|
+
return issues
|
|
148
|
+
|
|
149
|
+
def _create_issues(self, features: List[Dict[str, Any]], sprint_number: int) -> List[Issue]:
|
|
150
|
+
"""Create Issue objects from parsed features.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
features: List of feature dictionaries from parse_prd_features()
|
|
154
|
+
sprint_number: Sprint number for issue numbering
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
List of Issue objects
|
|
158
|
+
"""
|
|
159
|
+
issues = []
|
|
160
|
+
|
|
161
|
+
for idx, feature in enumerate(features, start=1):
|
|
162
|
+
# Generate issue number: {sprint}.{idx}
|
|
163
|
+
issue_number = f"{sprint_number}.{idx}"
|
|
164
|
+
|
|
165
|
+
# Assign priority based on raw text (includes keywords)
|
|
166
|
+
priority = assign_priority(feature["raw_text"])
|
|
167
|
+
|
|
168
|
+
# Create issue
|
|
169
|
+
issue = Issue(
|
|
170
|
+
issue_number=issue_number,
|
|
171
|
+
title=feature["title"],
|
|
172
|
+
description=feature["description"],
|
|
173
|
+
priority=priority,
|
|
174
|
+
status=TaskStatus.PENDING,
|
|
175
|
+
workflow_step=0,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
issues.append(issue)
|
|
179
|
+
self.logger.debug(
|
|
180
|
+
f"Created issue {issue_number}: {feature['title']} (priority={priority})"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
return issues
|
|
184
|
+
|
|
185
|
+
def _validate_issue(self, issue: Issue) -> None:
|
|
186
|
+
"""Validate issue meets requirements.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
issue: Issue to validate
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
ValueError: If issue is invalid
|
|
193
|
+
"""
|
|
194
|
+
# Validate title
|
|
195
|
+
if not issue.title or not issue.title.strip():
|
|
196
|
+
raise ValueError(f"Issue {issue.issue_number} must have a title")
|
|
197
|
+
|
|
198
|
+
# Validate issue number format (X.Y)
|
|
199
|
+
issue_number_pattern = r"^\d+\.\d+$"
|
|
200
|
+
if not re.match(issue_number_pattern, issue.issue_number):
|
|
201
|
+
raise ValueError(
|
|
202
|
+
f"Issue number '{issue.issue_number}' must match format X.Y "
|
|
203
|
+
f"(e.g., '2.1', '3.5')"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Validate priority range
|
|
207
|
+
if not (0 <= issue.priority <= 4):
|
|
208
|
+
raise ValueError(
|
|
209
|
+
f"Issue {issue.issue_number} priority must be 0-4, got {issue.priority}"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Validate status
|
|
213
|
+
if not isinstance(issue.status, TaskStatus):
|
|
214
|
+
raise ValueError(
|
|
215
|
+
f"Issue {issue.issue_number} status must be TaskStatus enum, "
|
|
216
|
+
f"got {type(issue.status)}"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
self.logger.debug(f"Validated issue {issue.issue_number}")
|