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,709 @@
|
|
|
1
|
+
"""V2 Pull Request router - delegates to git/github_integration module.
|
|
2
|
+
|
|
3
|
+
This module provides v2-style API endpoints for GitHub PR management.
|
|
4
|
+
Requires GITHUB_TOKEN and GITHUB_REPO environment variables.
|
|
5
|
+
|
|
6
|
+
Routes:
|
|
7
|
+
GET /api/v2/pr - List pull requests
|
|
8
|
+
GET /api/v2/pr/{number} - Get PR details
|
|
9
|
+
POST /api/v2/pr - Create a new PR
|
|
10
|
+
POST /api/v2/pr/{number}/merge - Merge a PR
|
|
11
|
+
POST /api/v2/pr/{number}/close - Close a PR without merging
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
19
|
+
from pydantic import BaseModel, Field
|
|
20
|
+
|
|
21
|
+
from codeframe.core.workspace import Workspace
|
|
22
|
+
from codeframe.lib.rate_limiter import rate_limit_standard
|
|
23
|
+
from codeframe.git.github_integration import GitHubIntegration, GitHubAPIError, PRDetails
|
|
24
|
+
from codeframe.ui.dependencies import get_v2_workspace
|
|
25
|
+
from codeframe.ui.response_models import api_error, ErrorCodes
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
router = APIRouter(prefix="/api/v2/pr", tags=["pr-v2"])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ============================================================================
|
|
33
|
+
# Request/Response Models
|
|
34
|
+
# ============================================================================
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PRResponse(BaseModel):
|
|
38
|
+
"""Response for a single pull request."""
|
|
39
|
+
|
|
40
|
+
number: int
|
|
41
|
+
url: str
|
|
42
|
+
state: str
|
|
43
|
+
title: str
|
|
44
|
+
body: Optional[str]
|
|
45
|
+
created_at: str
|
|
46
|
+
merged_at: Optional[str]
|
|
47
|
+
head_branch: str
|
|
48
|
+
base_branch: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PRListResponse(BaseModel):
|
|
52
|
+
"""Response for PR list."""
|
|
53
|
+
|
|
54
|
+
pull_requests: list[PRResponse]
|
|
55
|
+
total: int
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CreatePRRequest(BaseModel):
|
|
59
|
+
"""Request for creating a pull request."""
|
|
60
|
+
|
|
61
|
+
branch: str = Field(..., min_length=1, description="Head branch with changes")
|
|
62
|
+
title: str = Field(..., min_length=1, description="PR title")
|
|
63
|
+
body: str = Field("", description="PR description/body")
|
|
64
|
+
base: str = Field("main", description="Target branch to merge into")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class MergePRRequest(BaseModel):
|
|
68
|
+
"""Request for merging a pull request."""
|
|
69
|
+
|
|
70
|
+
method: str = Field("squash", description="Merge method: merge, squash, or rebase")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class MergeResponse(BaseModel):
|
|
74
|
+
"""Response for merge operation."""
|
|
75
|
+
|
|
76
|
+
sha: Optional[str]
|
|
77
|
+
merged: bool
|
|
78
|
+
message: str
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class CICheckResponse(BaseModel):
|
|
82
|
+
"""A single CI check run result."""
|
|
83
|
+
|
|
84
|
+
name: str
|
|
85
|
+
status: str
|
|
86
|
+
conclusion: Optional[str]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class PRStatusResponse(BaseModel):
|
|
90
|
+
"""Live PR status: CI checks, review status, and merge state."""
|
|
91
|
+
|
|
92
|
+
ci_checks: list[CICheckResponse]
|
|
93
|
+
review_status: str # "approved" | "changes_requested" | "pending"
|
|
94
|
+
merge_state: str # "open" | "merged" | "closed"
|
|
95
|
+
pr_url: str
|
|
96
|
+
pr_number: int
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class GateBreakdownItem(BaseModel):
|
|
100
|
+
"""A single gate pass/fail entry in a proof snapshot."""
|
|
101
|
+
|
|
102
|
+
gate: str
|
|
103
|
+
status: str
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ProofSnapshotOut(BaseModel):
|
|
107
|
+
"""Proof snapshot at time of PR creation."""
|
|
108
|
+
|
|
109
|
+
gates_passed: int
|
|
110
|
+
gates_total: int
|
|
111
|
+
gate_breakdown: list[GateBreakdownItem]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class PRHistoryItem(BaseModel):
|
|
115
|
+
"""A single merged PR with optional proof snapshot."""
|
|
116
|
+
|
|
117
|
+
number: int
|
|
118
|
+
title: str
|
|
119
|
+
merged_at: str
|
|
120
|
+
author: Optional[str]
|
|
121
|
+
url: str
|
|
122
|
+
proof_snapshot: Optional[ProofSnapshotOut]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class PRFilesResponse(BaseModel):
|
|
126
|
+
"""Response for PR changed files."""
|
|
127
|
+
|
|
128
|
+
files: list[str]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class PRHistoryResponse(BaseModel):
|
|
132
|
+
"""Response for PR history list."""
|
|
133
|
+
|
|
134
|
+
pull_requests: list[PRHistoryItem]
|
|
135
|
+
total: int
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ============================================================================
|
|
139
|
+
# Helper Functions
|
|
140
|
+
# ============================================================================
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _pr_to_response(pr: PRDetails) -> PRResponse:
|
|
144
|
+
"""Convert a PRDetails to a PRResponse."""
|
|
145
|
+
return PRResponse(
|
|
146
|
+
number=pr.number,
|
|
147
|
+
url=pr.url,
|
|
148
|
+
state=pr.state,
|
|
149
|
+
title=pr.title,
|
|
150
|
+
body=pr.body,
|
|
151
|
+
created_at=pr.created_at.isoformat(),
|
|
152
|
+
merged_at=pr.merged_at.isoformat() if pr.merged_at else None,
|
|
153
|
+
head_branch=pr.head_branch,
|
|
154
|
+
base_branch=pr.base_branch,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _get_github_client() -> GitHubIntegration:
|
|
159
|
+
"""Get a GitHub integration client.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
GitHubIntegration instance
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
HTTPException: If GitHub token or repo not configured
|
|
166
|
+
"""
|
|
167
|
+
try:
|
|
168
|
+
return GitHubIntegration()
|
|
169
|
+
except ValueError as e:
|
|
170
|
+
raise HTTPException(
|
|
171
|
+
status_code=400,
|
|
172
|
+
detail=api_error(
|
|
173
|
+
"GitHub not configured",
|
|
174
|
+
ErrorCodes.INVALID_REQUEST,
|
|
175
|
+
str(e),
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _dispatch_pr_merged_webhook(workspace: Workspace, pr_number: int) -> None:
|
|
181
|
+
"""Best-effort outbound webhook for ``pr.merged`` (issue #560).
|
|
182
|
+
|
|
183
|
+
Builds the canonical ``https://github.com/{owner}/{repo}/pull/{N}`` URL
|
|
184
|
+
from the GitHub integration when available; sets ``pr_url`` to ``None``
|
|
185
|
+
when the integration can't be constructed (e.g., ``GITHUB_REPO`` unset).
|
|
186
|
+
The always-present ``pr_number`` field lets consumers branch without
|
|
187
|
+
parsing a URL.
|
|
188
|
+
"""
|
|
189
|
+
try:
|
|
190
|
+
from codeframe.core.notifications_config import is_webhook_active
|
|
191
|
+
from codeframe.notifications.webhook import (
|
|
192
|
+
WebhookNotificationService,
|
|
193
|
+
format_pr_payload,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
url = is_webhook_active(workspace)
|
|
197
|
+
if url is None:
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
# Canonical github.com URL when GITHUB_REPO is configured, ``None``
|
|
201
|
+
# otherwise. The payload always carries ``pr_number`` so consumers
|
|
202
|
+
# can branch on it without parsing the URL.
|
|
203
|
+
try:
|
|
204
|
+
gh = GitHubIntegration()
|
|
205
|
+
pr_url: Optional[str] = f"https://github.com/{gh.repo}/pull/{pr_number}"
|
|
206
|
+
except Exception:
|
|
207
|
+
pr_url = None
|
|
208
|
+
|
|
209
|
+
svc = WebhookNotificationService(webhook_url=url, timeout=5)
|
|
210
|
+
svc.send_event_background(format_pr_payload(pr_number, pr_url))
|
|
211
|
+
except Exception:
|
|
212
|
+
logger.warning(
|
|
213
|
+
"Failed to dispatch pr.merged webhook for PR #%s", pr_number, exc_info=True
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ============================================================================
|
|
218
|
+
# Endpoints
|
|
219
|
+
# ============================================================================
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@router.get("/status", response_model=PRStatusResponse)
|
|
223
|
+
@rate_limit_standard()
|
|
224
|
+
async def get_pr_status(
|
|
225
|
+
request: Request,
|
|
226
|
+
pr_number: int = Query(..., description="PR number to poll"),
|
|
227
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
228
|
+
) -> PRStatusResponse:
|
|
229
|
+
"""Get live PR status: CI checks, review status, and merge state.
|
|
230
|
+
|
|
231
|
+
Polls the GitHub API for the given PR number and returns a snapshot
|
|
232
|
+
of all three status dimensions. The frontend polls this every 30 s
|
|
233
|
+
and stops when merge_state is merged or closed.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
pr_number: PR number to inspect
|
|
237
|
+
workspace: v2 Workspace (for context)
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
PRStatusResponse with CI checks, review status, and merge state
|
|
241
|
+
"""
|
|
242
|
+
# _get_github_client() raises HTTPException if GitHub isn't configured —
|
|
243
|
+
# no client to close in that case, so call it before the try/finally.
|
|
244
|
+
client = _get_github_client()
|
|
245
|
+
try:
|
|
246
|
+
# Single call to get PR state, URL, and head SHA.
|
|
247
|
+
pr_raw = await client._make_request(
|
|
248
|
+
"GET",
|
|
249
|
+
f"/repos/{client.owner}/{client.repo_name}/pulls/{pr_number}",
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Validate payload shape: non-dict responses (e.g. a list) or a dict with
|
|
253
|
+
# a non-dict "head" field would blow up before reaching the field checks.
|
|
254
|
+
if not isinstance(pr_raw, dict):
|
|
255
|
+
raise HTTPException(
|
|
256
|
+
status_code=502,
|
|
257
|
+
detail=api_error(
|
|
258
|
+
"Invalid GitHub response",
|
|
259
|
+
ErrorCodes.EXECUTION_FAILED,
|
|
260
|
+
"PR payload was not an object",
|
|
261
|
+
),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# Use safe access; raise 502 if required fields are absent rather than
|
|
265
|
+
# letting a KeyError bubble into an unhandled 500.
|
|
266
|
+
head = pr_raw.get("head")
|
|
267
|
+
head_sha: str | None = head.get("sha") if isinstance(head, dict) else None
|
|
268
|
+
pr_url: str | None = pr_raw.get("html_url")
|
|
269
|
+
state: str | None = pr_raw.get("state")
|
|
270
|
+
|
|
271
|
+
if not head_sha or not pr_url or not state:
|
|
272
|
+
raise HTTPException(
|
|
273
|
+
status_code=502,
|
|
274
|
+
detail=api_error(
|
|
275
|
+
"Invalid GitHub response",
|
|
276
|
+
ErrorCodes.EXECUTION_FAILED,
|
|
277
|
+
"Missing required fields (head.sha / html_url / state) in PR payload",
|
|
278
|
+
),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
merge_state: str = "merged" if pr_raw.get("merged_at") else state
|
|
282
|
+
|
|
283
|
+
# Fetch CI checks and reviews in parallel (2 more GitHub API calls).
|
|
284
|
+
ci_checks, review_status = await asyncio.gather(
|
|
285
|
+
client.get_pr_ci_checks(pr_number, head_sha=head_sha),
|
|
286
|
+
client.get_pr_review_status(pr_number),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
return PRStatusResponse(
|
|
290
|
+
ci_checks=[
|
|
291
|
+
CICheckResponse(name=c.name, status=c.status, conclusion=c.conclusion)
|
|
292
|
+
for c in ci_checks
|
|
293
|
+
],
|
|
294
|
+
review_status=review_status,
|
|
295
|
+
merge_state=merge_state,
|
|
296
|
+
pr_url=pr_url,
|
|
297
|
+
pr_number=pr_number,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
except GitHubAPIError as e:
|
|
301
|
+
if e.status_code == 404:
|
|
302
|
+
raise HTTPException(
|
|
303
|
+
status_code=404,
|
|
304
|
+
detail=api_error("PR not found", ErrorCodes.NOT_FOUND, f"No PR #{pr_number}"),
|
|
305
|
+
)
|
|
306
|
+
raise HTTPException(
|
|
307
|
+
status_code=e.status_code,
|
|
308
|
+
detail=api_error("GitHub API error", ErrorCodes.EXECUTION_FAILED, e.message),
|
|
309
|
+
)
|
|
310
|
+
except HTTPException:
|
|
311
|
+
raise
|
|
312
|
+
except Exception as e:
|
|
313
|
+
logger.error(f"Failed to get PR #{pr_number} status: {e}", exc_info=True)
|
|
314
|
+
raise HTTPException(
|
|
315
|
+
status_code=500,
|
|
316
|
+
detail=api_error("Failed to get PR status", ErrorCodes.EXECUTION_FAILED, str(e)),
|
|
317
|
+
)
|
|
318
|
+
finally:
|
|
319
|
+
await client.close()
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@router.get("", response_model=PRListResponse)
|
|
323
|
+
@rate_limit_standard()
|
|
324
|
+
async def list_pull_requests(
|
|
325
|
+
request: Request,
|
|
326
|
+
state: str = Query("open", description="Filter by state: open, closed, all"),
|
|
327
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
328
|
+
) -> PRListResponse:
|
|
329
|
+
"""List pull requests for the repository.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
state: Filter by PR state
|
|
333
|
+
workspace: v2 Workspace (for context)
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
List of pull requests
|
|
337
|
+
"""
|
|
338
|
+
client = _get_github_client()
|
|
339
|
+
try:
|
|
340
|
+
prs = await client.list_pull_requests(state=state)
|
|
341
|
+
|
|
342
|
+
return PRListResponse(
|
|
343
|
+
pull_requests=[_pr_to_response(pr) for pr in prs],
|
|
344
|
+
total=len(prs),
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
except GitHubAPIError as e:
|
|
348
|
+
raise HTTPException(
|
|
349
|
+
status_code=e.status_code,
|
|
350
|
+
detail=api_error("GitHub API error", ErrorCodes.EXECUTION_FAILED, e.message),
|
|
351
|
+
)
|
|
352
|
+
except HTTPException:
|
|
353
|
+
raise
|
|
354
|
+
except Exception as e:
|
|
355
|
+
logger.error(f"Failed to list PRs: {e}", exc_info=True)
|
|
356
|
+
raise HTTPException(
|
|
357
|
+
status_code=500,
|
|
358
|
+
detail=api_error("Failed to list PRs", ErrorCodes.EXECUTION_FAILED, str(e)),
|
|
359
|
+
)
|
|
360
|
+
finally:
|
|
361
|
+
await client.close()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@router.get("/history", response_model=PRHistoryResponse)
|
|
365
|
+
@rate_limit_standard()
|
|
366
|
+
async def get_pr_history(
|
|
367
|
+
request: Request,
|
|
368
|
+
limit: int = Query(10, ge=1, le=50),
|
|
369
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
370
|
+
) -> PRHistoryResponse:
|
|
371
|
+
"""List recently merged PRs with proof snapshots.
|
|
372
|
+
|
|
373
|
+
Returns merged PRs sorted by merged_at descending, each with an
|
|
374
|
+
optional proof snapshot showing gate pass/fail at PR creation time.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
limit: Maximum number of PRs to return (1-50, default 10)
|
|
378
|
+
workspace: v2 Workspace
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
PRHistoryResponse with merged PRs and proof snapshots
|
|
382
|
+
"""
|
|
383
|
+
from codeframe.core.proof.ledger import get_pr_proof_snapshot
|
|
384
|
+
|
|
385
|
+
client = _get_github_client()
|
|
386
|
+
try:
|
|
387
|
+
prs = await client.list_pull_requests(state="closed")
|
|
388
|
+
|
|
389
|
+
# Filter to only merged PRs and sort newest first.
|
|
390
|
+
merged = [pr for pr in prs if pr.merged_at is not None]
|
|
391
|
+
merged.sort(key=lambda pr: pr.merged_at, reverse=True)
|
|
392
|
+
merged = merged[:limit]
|
|
393
|
+
|
|
394
|
+
items: list[PRHistoryItem] = []
|
|
395
|
+
for pr in merged:
|
|
396
|
+
snapshot = get_pr_proof_snapshot(workspace, pr.number)
|
|
397
|
+
proof_snapshot = None
|
|
398
|
+
if snapshot:
|
|
399
|
+
proof_snapshot = ProofSnapshotOut(
|
|
400
|
+
gates_passed=snapshot["gates_passed"],
|
|
401
|
+
gates_total=snapshot["gates_total"],
|
|
402
|
+
gate_breakdown=[
|
|
403
|
+
GateBreakdownItem(**g) for g in snapshot["gate_breakdown"]
|
|
404
|
+
],
|
|
405
|
+
)
|
|
406
|
+
items.append(
|
|
407
|
+
PRHistoryItem(
|
|
408
|
+
number=pr.number,
|
|
409
|
+
title=pr.title,
|
|
410
|
+
merged_at=pr.merged_at.isoformat(),
|
|
411
|
+
author=pr.author,
|
|
412
|
+
url=pr.url,
|
|
413
|
+
proof_snapshot=proof_snapshot,
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
return PRHistoryResponse(pull_requests=items, total=len(items))
|
|
418
|
+
|
|
419
|
+
except GitHubAPIError as e:
|
|
420
|
+
raise HTTPException(
|
|
421
|
+
status_code=e.status_code,
|
|
422
|
+
detail=api_error("GitHub API error", ErrorCodes.EXECUTION_FAILED, e.message),
|
|
423
|
+
)
|
|
424
|
+
except HTTPException:
|
|
425
|
+
raise
|
|
426
|
+
except Exception as e:
|
|
427
|
+
logger.error(f"Failed to get PR history: {e}", exc_info=True)
|
|
428
|
+
raise HTTPException(
|
|
429
|
+
status_code=500,
|
|
430
|
+
detail=api_error("Failed to get PR history", ErrorCodes.EXECUTION_FAILED, str(e)),
|
|
431
|
+
)
|
|
432
|
+
finally:
|
|
433
|
+
await client.close()
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@router.get("/{pr_number}/files", response_model=PRFilesResponse)
|
|
437
|
+
@rate_limit_standard()
|
|
438
|
+
async def get_pr_files(
|
|
439
|
+
request: Request,
|
|
440
|
+
pr_number: int,
|
|
441
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
442
|
+
) -> PRFilesResponse:
|
|
443
|
+
"""Get the list of files changed in a pull request.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
pr_number: PR number
|
|
447
|
+
workspace: v2 Workspace (for context)
|
|
448
|
+
|
|
449
|
+
Returns:
|
|
450
|
+
List of changed filenames
|
|
451
|
+
"""
|
|
452
|
+
client = _get_github_client()
|
|
453
|
+
try:
|
|
454
|
+
files = await client.get_pr_files(pr_number)
|
|
455
|
+
return PRFilesResponse(files=files)
|
|
456
|
+
except GitHubAPIError as e:
|
|
457
|
+
if e.status_code == 404:
|
|
458
|
+
raise HTTPException(
|
|
459
|
+
status_code=404,
|
|
460
|
+
detail=api_error("PR not found", ErrorCodes.NOT_FOUND, f"No PR #{pr_number}"),
|
|
461
|
+
)
|
|
462
|
+
raise HTTPException(
|
|
463
|
+
status_code=e.status_code,
|
|
464
|
+
detail=api_error("GitHub API error", ErrorCodes.EXECUTION_FAILED, e.message),
|
|
465
|
+
)
|
|
466
|
+
except HTTPException:
|
|
467
|
+
raise
|
|
468
|
+
except Exception as e:
|
|
469
|
+
logger.error(f"Failed to get files for PR #{pr_number}: {e}", exc_info=True)
|
|
470
|
+
raise HTTPException(
|
|
471
|
+
status_code=500,
|
|
472
|
+
detail=api_error("Failed to get PR files", ErrorCodes.EXECUTION_FAILED, str(e)),
|
|
473
|
+
)
|
|
474
|
+
finally:
|
|
475
|
+
await client.close()
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
@router.get("/{pr_number}", response_model=PRResponse)
|
|
479
|
+
@rate_limit_standard()
|
|
480
|
+
async def get_pull_request(
|
|
481
|
+
request: Request,
|
|
482
|
+
pr_number: int,
|
|
483
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
484
|
+
) -> PRResponse:
|
|
485
|
+
"""Get details of a specific pull request.
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
pr_number: PR number
|
|
489
|
+
workspace: v2 Workspace (for context)
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
PR details
|
|
493
|
+
"""
|
|
494
|
+
client = _get_github_client()
|
|
495
|
+
try:
|
|
496
|
+
pr = await client.get_pull_request(pr_number)
|
|
497
|
+
|
|
498
|
+
return _pr_to_response(pr)
|
|
499
|
+
|
|
500
|
+
except GitHubAPIError as e:
|
|
501
|
+
if e.status_code == 404:
|
|
502
|
+
raise HTTPException(
|
|
503
|
+
status_code=404,
|
|
504
|
+
detail=api_error("PR not found", ErrorCodes.NOT_FOUND, f"No PR #{pr_number}"),
|
|
505
|
+
)
|
|
506
|
+
raise HTTPException(
|
|
507
|
+
status_code=e.status_code,
|
|
508
|
+
detail=api_error("GitHub API error", ErrorCodes.EXECUTION_FAILED, e.message),
|
|
509
|
+
)
|
|
510
|
+
except HTTPException:
|
|
511
|
+
raise
|
|
512
|
+
except Exception as e:
|
|
513
|
+
logger.error(f"Failed to get PR #{pr_number}: {e}", exc_info=True)
|
|
514
|
+
raise HTTPException(
|
|
515
|
+
status_code=500,
|
|
516
|
+
detail=api_error("Failed to get PR", ErrorCodes.EXECUTION_FAILED, str(e)),
|
|
517
|
+
)
|
|
518
|
+
finally:
|
|
519
|
+
await client.close()
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
@router.post("", response_model=PRResponse, status_code=201)
|
|
523
|
+
@rate_limit_standard()
|
|
524
|
+
async def create_pull_request(
|
|
525
|
+
request: Request,
|
|
526
|
+
body: CreatePRRequest,
|
|
527
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
528
|
+
) -> PRResponse:
|
|
529
|
+
"""Create a new pull request.
|
|
530
|
+
|
|
531
|
+
Args:
|
|
532
|
+
request: HTTP request for rate limiting
|
|
533
|
+
body: PR creation request
|
|
534
|
+
workspace: v2 Workspace (for context)
|
|
535
|
+
|
|
536
|
+
Returns:
|
|
537
|
+
Created PR details
|
|
538
|
+
"""
|
|
539
|
+
client = _get_github_client()
|
|
540
|
+
try:
|
|
541
|
+
pr = await client.create_pull_request(
|
|
542
|
+
branch=body.branch,
|
|
543
|
+
title=body.title,
|
|
544
|
+
body=body.body,
|
|
545
|
+
base=body.base,
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# Capture proof snapshot at PR creation time.
|
|
549
|
+
try:
|
|
550
|
+
from codeframe.core.proof.ledger import (
|
|
551
|
+
init_proof_tables,
|
|
552
|
+
list_requirements,
|
|
553
|
+
save_pr_proof_snapshot,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
init_proof_tables(workspace)
|
|
557
|
+
reqs = list_requirements(workspace)
|
|
558
|
+
|
|
559
|
+
gates_total = 0
|
|
560
|
+
gates_passed = 0
|
|
561
|
+
gate_breakdown: list[dict] = []
|
|
562
|
+
for req in reqs:
|
|
563
|
+
for ob in req.obligations:
|
|
564
|
+
gates_total += 1
|
|
565
|
+
passed = ob.status == "satisfied"
|
|
566
|
+
if passed:
|
|
567
|
+
gates_passed += 1
|
|
568
|
+
gate_breakdown.append({
|
|
569
|
+
"gate": ob.gate.value,
|
|
570
|
+
"status": ob.status,
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
save_pr_proof_snapshot(
|
|
574
|
+
workspace,
|
|
575
|
+
pr_number=pr.number,
|
|
576
|
+
gates_passed=gates_passed,
|
|
577
|
+
gates_total=gates_total,
|
|
578
|
+
gate_breakdown=gate_breakdown,
|
|
579
|
+
)
|
|
580
|
+
except Exception as snap_err:
|
|
581
|
+
logger.warning(f"Failed to save proof snapshot for PR #{pr.number}: {snap_err}")
|
|
582
|
+
|
|
583
|
+
return _pr_to_response(pr)
|
|
584
|
+
|
|
585
|
+
except GitHubAPIError as e:
|
|
586
|
+
raise HTTPException(
|
|
587
|
+
status_code=e.status_code,
|
|
588
|
+
detail=api_error("GitHub API error", ErrorCodes.EXECUTION_FAILED, e.message),
|
|
589
|
+
)
|
|
590
|
+
except HTTPException:
|
|
591
|
+
raise
|
|
592
|
+
except Exception as e:
|
|
593
|
+
logger.error(f"Failed to create PR: {e}", exc_info=True)
|
|
594
|
+
raise HTTPException(
|
|
595
|
+
status_code=500,
|
|
596
|
+
detail=api_error("Failed to create PR", ErrorCodes.EXECUTION_FAILED, str(e)),
|
|
597
|
+
)
|
|
598
|
+
finally:
|
|
599
|
+
await client.close()
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
@router.post("/{pr_number}/merge", response_model=MergeResponse)
|
|
603
|
+
@rate_limit_standard()
|
|
604
|
+
async def merge_pull_request(
|
|
605
|
+
request: Request,
|
|
606
|
+
pr_number: int,
|
|
607
|
+
body: MergePRRequest = None,
|
|
608
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
609
|
+
) -> MergeResponse:
|
|
610
|
+
"""Merge a pull request.
|
|
611
|
+
|
|
612
|
+
Args:
|
|
613
|
+
request: HTTP request for rate limiting
|
|
614
|
+
pr_number: PR number to merge
|
|
615
|
+
body: Merge options
|
|
616
|
+
workspace: v2 Workspace (for context)
|
|
617
|
+
|
|
618
|
+
Returns:
|
|
619
|
+
Merge result
|
|
620
|
+
"""
|
|
621
|
+
method = body.method if body else "squash"
|
|
622
|
+
|
|
623
|
+
client = _get_github_client()
|
|
624
|
+
try:
|
|
625
|
+
result = await client.merge_pull_request(pr_number, method=method)
|
|
626
|
+
|
|
627
|
+
# Outbound webhook for pr.merged (issue #560) — only on successful
|
|
628
|
+
# merge, never raises into the caller.
|
|
629
|
+
if result.merged:
|
|
630
|
+
_dispatch_pr_merged_webhook(workspace, pr_number)
|
|
631
|
+
|
|
632
|
+
return MergeResponse(
|
|
633
|
+
sha=result.sha,
|
|
634
|
+
merged=result.merged,
|
|
635
|
+
message=result.message,
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
except GitHubAPIError as e:
|
|
639
|
+
if e.status_code == 404:
|
|
640
|
+
raise HTTPException(
|
|
641
|
+
status_code=404,
|
|
642
|
+
detail=api_error("PR not found", ErrorCodes.NOT_FOUND, f"No PR #{pr_number}"),
|
|
643
|
+
)
|
|
644
|
+
if e.status_code == 405:
|
|
645
|
+
raise HTTPException(
|
|
646
|
+
status_code=400,
|
|
647
|
+
detail=api_error("Cannot merge", ErrorCodes.INVALID_STATE, e.message),
|
|
648
|
+
)
|
|
649
|
+
raise HTTPException(
|
|
650
|
+
status_code=e.status_code,
|
|
651
|
+
detail=api_error("GitHub API error", ErrorCodes.EXECUTION_FAILED, e.message),
|
|
652
|
+
)
|
|
653
|
+
except HTTPException:
|
|
654
|
+
raise
|
|
655
|
+
except Exception as e:
|
|
656
|
+
logger.error(f"Failed to merge PR #{pr_number}: {e}", exc_info=True)
|
|
657
|
+
raise HTTPException(
|
|
658
|
+
status_code=500,
|
|
659
|
+
detail=api_error("Failed to merge PR", ErrorCodes.EXECUTION_FAILED, str(e)),
|
|
660
|
+
)
|
|
661
|
+
finally:
|
|
662
|
+
await client.close()
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
@router.post("/{pr_number}/close")
|
|
666
|
+
@rate_limit_standard()
|
|
667
|
+
async def close_pull_request(
|
|
668
|
+
request: Request,
|
|
669
|
+
pr_number: int,
|
|
670
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
671
|
+
) -> dict:
|
|
672
|
+
"""Close a pull request without merging.
|
|
673
|
+
|
|
674
|
+
Args:
|
|
675
|
+
pr_number: PR number to close
|
|
676
|
+
workspace: v2 Workspace (for context)
|
|
677
|
+
|
|
678
|
+
Returns:
|
|
679
|
+
Close confirmation
|
|
680
|
+
"""
|
|
681
|
+
client = _get_github_client()
|
|
682
|
+
try:
|
|
683
|
+
closed = await client.close_pull_request(pr_number)
|
|
684
|
+
|
|
685
|
+
return {
|
|
686
|
+
"success": closed,
|
|
687
|
+
"message": f"PR #{pr_number} closed" if closed else f"Failed to close PR #{pr_number}",
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
except GitHubAPIError as e:
|
|
691
|
+
if e.status_code == 404:
|
|
692
|
+
raise HTTPException(
|
|
693
|
+
status_code=404,
|
|
694
|
+
detail=api_error("PR not found", ErrorCodes.NOT_FOUND, f"No PR #{pr_number}"),
|
|
695
|
+
)
|
|
696
|
+
raise HTTPException(
|
|
697
|
+
status_code=e.status_code,
|
|
698
|
+
detail=api_error("GitHub API error", ErrorCodes.EXECUTION_FAILED, e.message),
|
|
699
|
+
)
|
|
700
|
+
except HTTPException:
|
|
701
|
+
raise
|
|
702
|
+
except Exception as e:
|
|
703
|
+
logger.error(f"Failed to close PR #{pr_number}: {e}", exc_info=True)
|
|
704
|
+
raise HTTPException(
|
|
705
|
+
status_code=500,
|
|
706
|
+
detail=api_error("Failed to close PR", ErrorCodes.EXECUTION_FAILED, str(e)),
|
|
707
|
+
)
|
|
708
|
+
finally:
|
|
709
|
+
await client.close()
|