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,532 @@
1
+ """V2 GitHub integrations router — repository connection via PAT (issue #563).
2
+
3
+ Routes (prefix ``/api/v2/integrations/github``):
4
+ POST /connect - Validate PAT against repo, store PAT, save repo metadata
5
+ DELETE /disconnect - Clear stored PAT + repo metadata
6
+ GET /status - Report connection status (never exposes the PAT)
7
+ GET /issues - List the connected repo's open issues (#564)
8
+
9
+ The PAT is stored machine-wide via ``CredentialManager`` under
10
+ ``CredentialProvider.GIT_GITHUB`` — the same slot the API Keys settings tab
11
+ (#555) uses. Repo metadata (non-secret) is persisted per-workspace under
12
+ ``.codeframe/github_integration.json``. The PAT is never returned in any
13
+ response.
14
+ """
15
+
16
+ import logging
17
+ import sqlite3
18
+ import time
19
+ from typing import Any, Optional
20
+
21
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
22
+ from pydantic import BaseModel, Field
23
+
24
+ from codeframe.core.credentials import CredentialManager, CredentialProvider
25
+ from codeframe.core.github_connect_service import (
26
+ GitHubConnectError,
27
+ InsufficientScopeError,
28
+ InvalidTokenError,
29
+ RepoNotFoundError,
30
+ parse_repo,
31
+ validate_connection,
32
+ )
33
+ from codeframe.core.github_issues_service import (
34
+ IssueNotFoundError,
35
+ NotAnIssueError,
36
+ get_issue,
37
+ list_issues,
38
+ )
39
+ from codeframe.core.github_integration_config import (
40
+ clear_github_integration_config,
41
+ load_github_integration_config,
42
+ save_github_integration_config,
43
+ )
44
+ from codeframe.core import tasks
45
+ from codeframe.core.workspace import Workspace
46
+ from codeframe.lib.rate_limiter import rate_limit_ai, rate_limit_standard
47
+ from codeframe.ui.dependencies import get_v2_workspace
48
+ from codeframe.ui.response_models import ErrorCodes, api_error
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+ router = APIRouter(prefix="/api/v2/integrations/github", tags=["integrations"])
53
+
54
+
55
+ def get_credential_manager() -> CredentialManager:
56
+ """Dependency: machine-wide CredentialManager.
57
+
58
+ Overridden in tests to point at an isolated temp directory.
59
+ """
60
+ return CredentialManager()
61
+
62
+
63
+ class ConnectRequest(BaseModel):
64
+ pat: str = Field(..., min_length=1, description="GitHub Personal Access Token")
65
+ repo: str = Field(..., description="Repository in 'owner/repo' format")
66
+
67
+
68
+ class ConnectResponse(BaseModel):
69
+ connected: bool
70
+ repo: str
71
+ owner_login: str
72
+ owner_avatar_url: str
73
+
74
+
75
+ class StatusResponse(BaseModel):
76
+ connected: bool
77
+ repo: Optional[str] = None
78
+ owner_login: Optional[str] = None
79
+ owner_avatar_url: Optional[str] = None
80
+
81
+
82
+ class GitHubIssueItem(BaseModel):
83
+ number: int
84
+ title: str
85
+ labels: list[str]
86
+ assignee: Optional[str] = None
87
+ created_at: str
88
+ html_url: str
89
+
90
+
91
+ class GitHubIssuesResponse(BaseModel):
92
+ issues: list[GitHubIssueItem]
93
+ total: int
94
+ page: int
95
+ per_page: int
96
+
97
+
98
+ class ImportRequest(BaseModel):
99
+ issue_numbers: list[int] = Field(
100
+ ..., min_length=1, description="GitHub issue numbers to import as tasks"
101
+ )
102
+
103
+
104
+ class ImportedTaskSummary(BaseModel):
105
+ task_id: str
106
+ issue_number: int
107
+ title: str
108
+
109
+
110
+ class ImportResponse(BaseModel):
111
+ created: list[ImportedTaskSummary]
112
+ skipped: list[int]
113
+ total_created: int
114
+
115
+
116
+ # In-process TTL cache for issue listings (#564). Keyed by the full query
117
+ # (repo + page + per_page + search + label); entries expire after 60s to avoid
118
+ # hammering GitHub's rate limit on repeated browses. Module-level so it is
119
+ # shared across requests within the same server process.
120
+ _ISSUE_CACHE_TTL_SECONDS = 60.0
121
+ _ISSUE_CACHE: dict[str, tuple[float, Any]] = {}
122
+
123
+
124
+ def _issue_cache_get(key: str) -> Optional[Any]:
125
+ entry = _ISSUE_CACHE.get(key)
126
+ if entry is None:
127
+ return None
128
+ expires_at, payload = entry
129
+ if time.monotonic() >= expires_at:
130
+ _ISSUE_CACHE.pop(key, None)
131
+ return None
132
+ return payload
133
+
134
+
135
+ def _issue_cache_set(key: str, payload: Any) -> None:
136
+ _ISSUE_CACHE[key] = (time.monotonic() + _ISSUE_CACHE_TTL_SECONDS, payload)
137
+
138
+
139
+ def _issue_cache_invalidate(repo: str) -> None:
140
+ """Drop all cached issue listings for ``repo``.
141
+
142
+ Called after an import so reopening the browse modal doesn't keep offering
143
+ just-imported issues as selectable (they would now be skipped as dupes).
144
+ """
145
+ prefix = f"{repo}|"
146
+ for key in [k for k in _ISSUE_CACHE if k.startswith(prefix)]:
147
+ _ISSUE_CACHE.pop(key, None)
148
+
149
+
150
+ @router.get("/status", response_model=StatusResponse)
151
+ @rate_limit_standard()
152
+ async def get_status(
153
+ request: Request,
154
+ workspace: Workspace = Depends(get_v2_workspace),
155
+ manager: CredentialManager = Depends(get_credential_manager),
156
+ ) -> StatusResponse:
157
+ """Report whether a GitHub repo is connected for this workspace.
158
+
159
+ Connected means BOTH the per-workspace repo metadata exists AND the
160
+ machine-wide GitHub PAT is present (env var or stored).
161
+ """
162
+ cfg = load_github_integration_config(workspace)
163
+ has_pat = manager.get_credential(CredentialProvider.GIT_GITHUB) is not None
164
+ if cfg is None or not has_pat:
165
+ return StatusResponse(connected=False)
166
+ return StatusResponse(
167
+ connected=True,
168
+ repo=cfg["repo"],
169
+ owner_login=cfg["owner_login"] or None,
170
+ owner_avatar_url=cfg["owner_avatar_url"] or None,
171
+ )
172
+
173
+
174
+ @router.post("/connect", response_model=ConnectResponse)
175
+ @rate_limit_ai()
176
+ async def connect(
177
+ request: Request,
178
+ body: ConnectRequest,
179
+ workspace: Workspace = Depends(get_v2_workspace),
180
+ manager: CredentialManager = Depends(get_credential_manager),
181
+ ) -> ConnectResponse:
182
+ """Validate a PAT against the target repo, then store the PAT + repo metadata.
183
+
184
+ The PAT is validated against the GitHub API (token validity, repo
185
+ visibility, issues-read access) BEFORE anything is persisted. On any
186
+ validation failure nothing is stored.
187
+ """
188
+ # Validate 'owner/repo' format at the boundary before any network call.
189
+ try:
190
+ parse_repo(body.repo)
191
+ except ValueError as e:
192
+ raise HTTPException(
193
+ status_code=400,
194
+ detail=api_error(str(e), ErrorCodes.VALIDATION_ERROR),
195
+ )
196
+
197
+ try:
198
+ result = await validate_connection(body.pat, body.repo)
199
+ except ValueError as e:
200
+ # Defensive: service re-validates format too.
201
+ raise HTTPException(
202
+ status_code=400,
203
+ detail=api_error(str(e), ErrorCodes.VALIDATION_ERROR),
204
+ )
205
+ except InvalidTokenError as e:
206
+ raise HTTPException(
207
+ status_code=401,
208
+ detail=api_error(str(e), ErrorCodes.VALIDATION_ERROR),
209
+ )
210
+ except RepoNotFoundError as e:
211
+ raise HTTPException(
212
+ status_code=404,
213
+ detail=api_error(str(e), ErrorCodes.NOT_FOUND),
214
+ )
215
+ except InsufficientScopeError as e:
216
+ raise HTTPException(
217
+ status_code=403,
218
+ detail=api_error(str(e), ErrorCodes.VALIDATION_ERROR),
219
+ )
220
+ except GitHubConnectError as e:
221
+ raise HTTPException(
222
+ status_code=502,
223
+ detail=api_error(str(e), ErrorCodes.EXECUTION_FAILED),
224
+ )
225
+
226
+ # Validation passed — store the PAT (machine-wide) and repo metadata.
227
+ # The GIT_GITHUB slot is shared machine-wide with the API Keys tab, so
228
+ # capture any prior token first to restore it if the config write fails —
229
+ # never blindly delete an unrelated, previously working credential.
230
+ prior_pat = manager.get_credential(CredentialProvider.GIT_GITHUB)
231
+ try:
232
+ manager.set_credential(CredentialProvider.GIT_GITHUB, body.pat)
233
+ except Exception as e:
234
+ logger.error("Failed to store GitHub PAT: %s", e, exc_info=True)
235
+ raise HTTPException(
236
+ status_code=500,
237
+ detail=api_error(
238
+ "Failed to store GitHub token", ErrorCodes.EXECUTION_FAILED, str(e)
239
+ ),
240
+ )
241
+
242
+ try:
243
+ saved = save_github_integration_config(
244
+ workspace,
245
+ {
246
+ "repo": result["repo_full_name"],
247
+ "owner_login": result["owner_login"],
248
+ "owner_avatar_url": result["owner_avatar_url"],
249
+ },
250
+ )
251
+ except OSError as e:
252
+ # Roll back the credential so we don't leave a half-connected state.
253
+ # Restore the prior token if there was one; only delete when the slot
254
+ # was empty before this request.
255
+ logger.error("Failed to save integration config: %s", e, exc_info=True)
256
+ try:
257
+ if prior_pat is not None:
258
+ manager.set_credential(CredentialProvider.GIT_GITHUB, prior_pat)
259
+ else:
260
+ manager.delete_credential(CredentialProvider.GIT_GITHUB)
261
+ except Exception:
262
+ pass
263
+ raise HTTPException(
264
+ status_code=500,
265
+ detail=api_error(
266
+ "Failed to save integration config",
267
+ ErrorCodes.EXECUTION_FAILED,
268
+ str(e),
269
+ ),
270
+ )
271
+
272
+ return ConnectResponse(
273
+ connected=True,
274
+ repo=saved["repo"],
275
+ owner_login=saved["owner_login"],
276
+ owner_avatar_url=saved["owner_avatar_url"],
277
+ )
278
+
279
+
280
+ @router.delete("/disconnect", status_code=204)
281
+ @rate_limit_standard()
282
+ async def disconnect(
283
+ request: Request,
284
+ workspace: Workspace = Depends(get_v2_workspace),
285
+ manager: CredentialManager = Depends(get_credential_manager),
286
+ ) -> Response:
287
+ """Clear stored repo metadata and delete the GitHub PAT. Idempotent."""
288
+ clear_github_integration_config(workspace)
289
+ try:
290
+ manager.delete_credential(CredentialProvider.GIT_GITHUB)
291
+ except Exception as e:
292
+ # Treat absence as success; only surface hard failures.
293
+ msg = str(e).lower()
294
+ if "no such" not in msg and "not found" not in msg:
295
+ logger.warning("Failed to delete GitHub PAT on disconnect: %s", e)
296
+ return Response(status_code=204)
297
+
298
+
299
+ @router.get("/issues", response_model=GitHubIssuesResponse)
300
+ @rate_limit_standard()
301
+ async def get_issues(
302
+ request: Request,
303
+ page: int = Query(1, ge=1, description="1-indexed page number"),
304
+ per_page: int = Query(25, description="Page size (clamped to 1..100)"),
305
+ search: str = Query("", description="Free-text title/body search"),
306
+ label: str = Query("", description="Filter by a single label name"),
307
+ workspace: Workspace = Depends(get_v2_workspace),
308
+ manager: CredentialManager = Depends(get_credential_manager),
309
+ ) -> GitHubIssuesResponse:
310
+ """List the connected repository's **open** issues for the import browser.
311
+
312
+ Requires an established connection (#563): repo metadata in
313
+ ``.codeframe/github_integration.json`` AND a stored GitHub PAT. Responses
314
+ are cached in-process for 60s keyed by the full query to avoid GitHub
315
+ rate-limit pressure during paging/searching. The PAT is never returned.
316
+ """
317
+ cfg = load_github_integration_config(workspace)
318
+ pat = manager.get_credential(CredentialProvider.GIT_GITHUB)
319
+ if cfg is None or not pat:
320
+ raise HTTPException(
321
+ status_code=409,
322
+ detail=api_error(
323
+ "No GitHub repository is connected. Connect one in Settings → "
324
+ "Integrations first.",
325
+ ErrorCodes.CONFLICT,
326
+ ),
327
+ )
328
+
329
+ # GitHub caps per_page at 100; clamp to a sane 1..100 window.
330
+ per_page = max(1, min(per_page, 100))
331
+ repo = cfg["repo"]
332
+
333
+ cache_key = f"{repo}|{page}|{per_page}|{search}|{label}"
334
+ cached = _issue_cache_get(cache_key)
335
+ if cached is not None:
336
+ return cached
337
+
338
+ try:
339
+ issues, total = await list_issues(
340
+ pat,
341
+ repo,
342
+ page=page,
343
+ per_page=per_page,
344
+ search=search,
345
+ label=label,
346
+ )
347
+ except ValueError as e:
348
+ # Stored repo metadata is malformed — surface as a conflict, not a 500.
349
+ raise HTTPException(
350
+ status_code=409,
351
+ detail=api_error(str(e), ErrorCodes.CONFLICT),
352
+ )
353
+ except InvalidTokenError as e:
354
+ raise HTTPException(
355
+ status_code=401,
356
+ detail=api_error(str(e), ErrorCodes.VALIDATION_ERROR),
357
+ )
358
+ except InsufficientScopeError as e:
359
+ raise HTTPException(
360
+ status_code=403,
361
+ detail=api_error(str(e), ErrorCodes.VALIDATION_ERROR),
362
+ )
363
+ except GitHubConnectError as e:
364
+ raise HTTPException(
365
+ status_code=502,
366
+ detail=api_error(str(e), ErrorCodes.EXECUTION_FAILED),
367
+ )
368
+
369
+ response = GitHubIssuesResponse(
370
+ issues=[GitHubIssueItem(**issue) for issue in issues],
371
+ total=total,
372
+ page=page,
373
+ per_page=per_page,
374
+ )
375
+ _issue_cache_set(cache_key, response)
376
+ return response
377
+
378
+
379
+ def _require_connection(
380
+ workspace: Workspace, manager: CredentialManager
381
+ ) -> tuple[str, str]:
382
+ """Return ``(repo, pat)`` for a connected workspace, or raise 409.
383
+
384
+ Mirrors the not-connected guard used by ``get_issues``: both per-workspace
385
+ repo metadata AND a stored PAT must be present.
386
+ """
387
+ cfg = load_github_integration_config(workspace)
388
+ pat = manager.get_credential(CredentialProvider.GIT_GITHUB)
389
+ if cfg is None or not pat:
390
+ raise HTTPException(
391
+ status_code=409,
392
+ detail=api_error(
393
+ "No GitHub repository is connected. Connect one in Settings → "
394
+ "Integrations first.",
395
+ ErrorCodes.CONFLICT,
396
+ ),
397
+ )
398
+ return cfg["repo"], pat
399
+
400
+
401
+ def _map_github_error(e: Exception) -> HTTPException:
402
+ """Map a typed GitHub service error to an HTTPException (shared mapping)."""
403
+ if isinstance(e, InvalidTokenError):
404
+ return HTTPException(
405
+ status_code=401, detail=api_error(str(e), ErrorCodes.VALIDATION_ERROR)
406
+ )
407
+ if isinstance(e, InsufficientScopeError):
408
+ return HTTPException(
409
+ status_code=403, detail=api_error(str(e), ErrorCodes.VALIDATION_ERROR)
410
+ )
411
+ return HTTPException(
412
+ status_code=502, detail=api_error(str(e), ErrorCodes.EXECUTION_FAILED)
413
+ )
414
+
415
+
416
+ @router.post("/import", response_model=ImportResponse)
417
+ @rate_limit_ai()
418
+ async def import_issues(
419
+ request: Request,
420
+ body: ImportRequest,
421
+ workspace: Workspace = Depends(get_v2_workspace),
422
+ manager: CredentialManager = Depends(get_credential_manager),
423
+ ) -> ImportResponse:
424
+ """Import selected GitHub issues as CodeFRAME tasks (issue #565).
425
+
426
+ Each issue becomes a task (title + body, with a best-effort ``Labels:``
427
+ footer) linked back to the issue via ``github_issue_number`` + ``external_url``.
428
+ Issues already imported into this workspace are skipped (duplicate-import
429
+ protection), keyed on the full issue URL so the same number in a different
430
+ repo is still importable.
431
+
432
+ The import is all-or-nothing on fetch: every selected issue is fetched and
433
+ de-duplicated *before* any task is created, so a single stale/inaccessible
434
+ issue fails the request cleanly without leaving a partial set of tasks
435
+ behind (which would confuse a retry with phantom duplicates).
436
+ """
437
+ repo, pat = _require_connection(workspace, manager)
438
+
439
+ skipped: list[int] = []
440
+ to_create: list[tuple[int, dict]] = []
441
+ seen_urls: set[str] = set()
442
+
443
+ # Phase 1 — fetch + de-dupe everything first. Any fetch error aborts here,
444
+ # before a single task has been created.
445
+ for number in body.issue_numbers:
446
+ try:
447
+ issue = await get_issue(pat, repo, number)
448
+ except ValueError as e:
449
+ # Malformed saved repo slug (parse_repo) — surface as a recoverable
450
+ # conflict like the browse endpoint, not a 500.
451
+ raise HTTPException(
452
+ status_code=409,
453
+ detail=api_error(str(e), ErrorCodes.CONFLICT),
454
+ )
455
+ except NotAnIssueError as e:
456
+ # A PR number slipped in (the browse UI filters PRs, so this only
457
+ # happens with a malformed/manual payload). Reject the request.
458
+ raise HTTPException(
459
+ status_code=422,
460
+ detail=api_error(str(e), ErrorCodes.VALIDATION_ERROR),
461
+ )
462
+ except IssueNotFoundError as e:
463
+ # A stale/typo'd issue number — a client error, not a 502.
464
+ raise HTTPException(
465
+ status_code=404,
466
+ detail=api_error(str(e), ErrorCodes.NOT_FOUND),
467
+ )
468
+ except GitHubConnectError as e:
469
+ raise _map_github_error(e)
470
+
471
+ url = issue["html_url"]
472
+ # Skip both already-imported issues and in-payload duplicates (the same
473
+ # number repeated in one request must not create two tasks).
474
+ if url in seen_urls or tasks.get_by_external_url(workspace, url) is not None:
475
+ skipped.append(number)
476
+ continue
477
+ seen_urls.add(url)
478
+ to_create.append((number, issue))
479
+
480
+ # Phase 2 — create the tasks. Each create commits independently, so an
481
+ # unexpected mid-loop DB error (e.g. OperationalError on a locked DB) is
482
+ # rolled back below to preserve the all-or-nothing contract. (The
483
+ # URL-unique index additionally makes a retry idempotent.)
484
+ created: list[ImportedTaskSummary] = []
485
+ created_ids: list[str] = []
486
+ try:
487
+ for number, issue in to_create:
488
+ description = issue["body"] or ""
489
+ if issue["labels"]:
490
+ footer = "**Labels:** " + ", ".join(issue["labels"])
491
+ description = f"{description}\n\n{footer}" if description else footer
492
+
493
+ try:
494
+ task = tasks.create(
495
+ workspace,
496
+ title=issue["title"],
497
+ description=description,
498
+ github_issue_number=number,
499
+ external_url=issue["html_url"],
500
+ )
501
+ except sqlite3.IntegrityError:
502
+ # A concurrent import (double-submit / second tab) created this
503
+ # task between our de-dupe read and now. The unique index on
504
+ # (workspace_id, external_url) makes that race a no-op: treat it
505
+ # as an already-imported skip rather than a duplicate task.
506
+ skipped.append(number)
507
+ continue
508
+
509
+ created_ids.append(task.id)
510
+ created.append(
511
+ ImportedTaskSummary(
512
+ task_id=task.id, issue_number=number, title=task.title
513
+ )
514
+ )
515
+ except Exception:
516
+ # Roll back the tasks created so far so a mid-create failure never leaves
517
+ # a partial import behind.
518
+ for task_id in created_ids:
519
+ try:
520
+ tasks.delete(workspace, task_id)
521
+ except Exception: # noqa: BLE001 - best-effort cleanup
522
+ logger.warning("Failed to roll back imported task %s", task_id)
523
+ raise
524
+
525
+ # Invalidate the browse cache so a re-open reflects current duplicate state.
526
+ # Always — even a skipped-only import (issues created by another tab/process)
527
+ # must drop the stale listing that still offers them as selectable.
528
+ _issue_cache_invalidate(repo)
529
+
530
+ return ImportResponse(
531
+ created=created, skipped=skipped, total_created=len(created)
532
+ )