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.
Files changed (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. 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()