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,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
|
+
)
|