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
codeframe/core/git.py
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""Git operations for CodeFRAME v2.
|
|
2
|
+
|
|
3
|
+
This module provides v2-compatible git operations that work with
|
|
4
|
+
the Workspace model. It uses GitPython directly without requiring
|
|
5
|
+
the v1 database.
|
|
6
|
+
|
|
7
|
+
This module is headless - no FastAPI or HTTP dependencies.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
import git
|
|
17
|
+
|
|
18
|
+
from codeframe.core.workspace import Workspace
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ============================================================================
|
|
24
|
+
# Data Classes
|
|
25
|
+
# ============================================================================
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class GitStatus:
|
|
30
|
+
"""Git working tree status."""
|
|
31
|
+
|
|
32
|
+
current_branch: str
|
|
33
|
+
is_dirty: bool
|
|
34
|
+
modified_files: list[str]
|
|
35
|
+
untracked_files: list[str]
|
|
36
|
+
staged_files: list[str]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class CommitInfo:
|
|
41
|
+
"""Git commit information."""
|
|
42
|
+
|
|
43
|
+
hash: str
|
|
44
|
+
short_hash: str
|
|
45
|
+
message: str
|
|
46
|
+
author: str
|
|
47
|
+
timestamp: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class CommitResult:
|
|
52
|
+
"""Result of a commit operation."""
|
|
53
|
+
|
|
54
|
+
commit_hash: str
|
|
55
|
+
commit_message: str
|
|
56
|
+
files_changed: int
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class FileChange:
|
|
61
|
+
"""Per-file change statistics from a diff."""
|
|
62
|
+
|
|
63
|
+
path: str
|
|
64
|
+
change_type: str # "modified", "added", "deleted", "renamed"
|
|
65
|
+
insertions: int = 0
|
|
66
|
+
deletions: int = 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class DiffStats:
|
|
71
|
+
"""Parsed diff statistics."""
|
|
72
|
+
|
|
73
|
+
diff: str
|
|
74
|
+
files_changed: int
|
|
75
|
+
insertions: int
|
|
76
|
+
deletions: int
|
|
77
|
+
changed_files: list[FileChange] = field(default_factory=list)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ============================================================================
|
|
81
|
+
# Git Operations
|
|
82
|
+
# ============================================================================
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _get_repo(workspace: Workspace) -> git.Repo:
|
|
86
|
+
"""Get git repo for a workspace.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
workspace: Target workspace
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
GitPython Repo object
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
ValueError: If workspace is not a git repository
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
return git.Repo(workspace.repo_path)
|
|
99
|
+
except git.InvalidGitRepositoryError:
|
|
100
|
+
raise ValueError(f"Not a git repository: {workspace.repo_path}")
|
|
101
|
+
except git.NoSuchPathError:
|
|
102
|
+
raise ValueError(f"Path does not exist: {workspace.repo_path}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_status(workspace: Workspace) -> GitStatus:
|
|
106
|
+
"""Get git working tree status for a workspace.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
workspace: Target workspace
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
GitStatus with branch and file states
|
|
113
|
+
"""
|
|
114
|
+
repo = _get_repo(workspace)
|
|
115
|
+
|
|
116
|
+
# Get current branch
|
|
117
|
+
try:
|
|
118
|
+
current_branch = repo.active_branch.name
|
|
119
|
+
except TypeError:
|
|
120
|
+
# Detached HEAD or empty repo
|
|
121
|
+
if not repo.head.is_valid():
|
|
122
|
+
current_branch = "(no commits)"
|
|
123
|
+
else:
|
|
124
|
+
current_branch = f"(detached HEAD at {repo.head.commit.hexsha[:7]})"
|
|
125
|
+
|
|
126
|
+
# Check if dirty
|
|
127
|
+
is_dirty = repo.is_dirty(untracked_files=True)
|
|
128
|
+
|
|
129
|
+
# Get modified files (tracked, unstaged changes)
|
|
130
|
+
modified_files = [item.a_path for item in repo.index.diff(None)]
|
|
131
|
+
|
|
132
|
+
# Get untracked files
|
|
133
|
+
untracked_files = list(repo.untracked_files)
|
|
134
|
+
|
|
135
|
+
# Get staged files (handle repos with no commits/HEAD)
|
|
136
|
+
staged_files: list[str] = []
|
|
137
|
+
try:
|
|
138
|
+
if repo.head.is_valid():
|
|
139
|
+
staged_files = [item.a_path for item in repo.index.diff("HEAD")]
|
|
140
|
+
else:
|
|
141
|
+
# No HEAD yet - all indexed files are staged
|
|
142
|
+
staged_files = [path for path, _stage in repo.index.entries.keys()]
|
|
143
|
+
except git.BadName:
|
|
144
|
+
# HEAD reference doesn't exist (empty repo)
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
return GitStatus(
|
|
148
|
+
current_branch=current_branch,
|
|
149
|
+
is_dirty=is_dirty,
|
|
150
|
+
modified_files=modified_files,
|
|
151
|
+
untracked_files=untracked_files,
|
|
152
|
+
staged_files=staged_files,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def list_commits(
|
|
157
|
+
workspace: Workspace,
|
|
158
|
+
branch: Optional[str] = None,
|
|
159
|
+
limit: int = 50,
|
|
160
|
+
) -> list[CommitInfo]:
|
|
161
|
+
"""List git commits for a workspace.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
workspace: Target workspace
|
|
165
|
+
branch: Optional branch name (default: current branch)
|
|
166
|
+
limit: Maximum number of commits to return
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
List of CommitInfo objects
|
|
170
|
+
"""
|
|
171
|
+
repo = _get_repo(workspace)
|
|
172
|
+
|
|
173
|
+
commits: list[CommitInfo] = []
|
|
174
|
+
try:
|
|
175
|
+
if branch:
|
|
176
|
+
commits_iter = repo.iter_commits(branch, max_count=limit)
|
|
177
|
+
else:
|
|
178
|
+
commits_iter = repo.iter_commits(max_count=limit)
|
|
179
|
+
|
|
180
|
+
for commit in commits_iter:
|
|
181
|
+
commits.append(
|
|
182
|
+
CommitInfo(
|
|
183
|
+
hash=commit.hexsha,
|
|
184
|
+
short_hash=commit.hexsha[:7],
|
|
185
|
+
message=commit.message.strip().split("\n")[0],
|
|
186
|
+
author=str(commit.author),
|
|
187
|
+
timestamp=commit.committed_datetime.isoformat(),
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
except git.GitCommandError as e:
|
|
191
|
+
logger.warning(f"Failed to list commits: {e}")
|
|
192
|
+
except git.BadName as e:
|
|
193
|
+
logger.warning(f"Invalid branch reference: {e}")
|
|
194
|
+
|
|
195
|
+
return commits
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def create_commit(
|
|
199
|
+
workspace: Workspace,
|
|
200
|
+
files: list[str],
|
|
201
|
+
message: str,
|
|
202
|
+
) -> CommitResult:
|
|
203
|
+
"""Create a git commit with specified files.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
workspace: Target workspace
|
|
207
|
+
files: List of file paths to commit (relative to repo root)
|
|
208
|
+
message: Commit message
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
CommitResult with commit details
|
|
212
|
+
|
|
213
|
+
Raises:
|
|
214
|
+
ValueError: If no files provided or commit fails
|
|
215
|
+
"""
|
|
216
|
+
if not files:
|
|
217
|
+
raise ValueError("No files to commit")
|
|
218
|
+
|
|
219
|
+
if not message or not message.strip():
|
|
220
|
+
raise ValueError("Commit message cannot be empty")
|
|
221
|
+
|
|
222
|
+
repo = _get_repo(workspace)
|
|
223
|
+
|
|
224
|
+
# Validate file paths exist and are within repo
|
|
225
|
+
repo_root = Path(repo.working_tree_dir).resolve()
|
|
226
|
+
valid_files: list[str] = []
|
|
227
|
+
for file_path in files:
|
|
228
|
+
candidate = (repo_root / file_path).resolve()
|
|
229
|
+
# Security: Ensure path stays within repo root
|
|
230
|
+
try:
|
|
231
|
+
candidate.relative_to(repo_root)
|
|
232
|
+
except ValueError:
|
|
233
|
+
logger.warning(f"File outside repo, skipping: {file_path}")
|
|
234
|
+
continue
|
|
235
|
+
if candidate.exists():
|
|
236
|
+
valid_files.append(str(candidate.relative_to(repo_root)))
|
|
237
|
+
else:
|
|
238
|
+
logger.warning(f"File not found, skipping: {file_path}")
|
|
239
|
+
|
|
240
|
+
if not valid_files:
|
|
241
|
+
raise ValueError("None of the specified files exist")
|
|
242
|
+
|
|
243
|
+
# Stage files
|
|
244
|
+
repo.index.add(valid_files)
|
|
245
|
+
|
|
246
|
+
# Create commit
|
|
247
|
+
commit = repo.index.commit(message.strip())
|
|
248
|
+
|
|
249
|
+
logger.info(f"Created commit {commit.hexsha[:7]}: {message.strip()[:50]}")
|
|
250
|
+
|
|
251
|
+
return CommitResult(
|
|
252
|
+
commit_hash=commit.hexsha,
|
|
253
|
+
commit_message=message.strip(),
|
|
254
|
+
files_changed=len(valid_files),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def get_diff(
|
|
259
|
+
workspace: Workspace,
|
|
260
|
+
staged: bool = False,
|
|
261
|
+
) -> str:
|
|
262
|
+
"""Get git diff for a workspace.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
workspace: Target workspace
|
|
266
|
+
staged: If True, show staged changes; if False, show unstaged changes
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Diff as string
|
|
270
|
+
"""
|
|
271
|
+
repo = _get_repo(workspace)
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
if staged:
|
|
275
|
+
# Staged changes (compared to HEAD)
|
|
276
|
+
if repo.head.is_valid():
|
|
277
|
+
return repo.git.diff("--cached")
|
|
278
|
+
return ""
|
|
279
|
+
else:
|
|
280
|
+
# Unstaged changes (working tree vs index)
|
|
281
|
+
return repo.git.diff()
|
|
282
|
+
except git.GitCommandError as e:
|
|
283
|
+
logger.warning(f"Failed to get diff: {e}")
|
|
284
|
+
return ""
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def get_current_branch(workspace: Workspace) -> str:
|
|
288
|
+
"""Get current branch name for a workspace.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
workspace: Target workspace
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
Branch name or detached HEAD indicator
|
|
295
|
+
"""
|
|
296
|
+
repo = _get_repo(workspace)
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
return repo.active_branch.name
|
|
300
|
+
except TypeError:
|
|
301
|
+
# Detached HEAD or empty repo
|
|
302
|
+
if not repo.head.is_valid():
|
|
303
|
+
return "(no commits)"
|
|
304
|
+
return f"(detached HEAD at {repo.head.commit.hexsha[:7]})"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def is_clean(workspace: Workspace) -> bool:
|
|
308
|
+
"""Check if workspace has no uncommitted changes.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
workspace: Target workspace
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
True if working tree is clean
|
|
315
|
+
"""
|
|
316
|
+
repo = _get_repo(workspace)
|
|
317
|
+
return not repo.is_dirty(untracked_files=True)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def get_diff_stats(workspace: Workspace, staged: bool = False) -> DiffStats:
|
|
321
|
+
"""Get diff with parsed statistics.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
workspace: Target workspace
|
|
325
|
+
staged: If True, show staged changes; if False, show unstaged
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
DiffStats with parsed per-file statistics
|
|
329
|
+
"""
|
|
330
|
+
repo = _get_repo(workspace)
|
|
331
|
+
diff_text = get_diff(workspace, staged=staged)
|
|
332
|
+
|
|
333
|
+
if not diff_text.strip():
|
|
334
|
+
return DiffStats(diff=diff_text, files_changed=0, insertions=0, deletions=0)
|
|
335
|
+
|
|
336
|
+
# Use git diff --stat for accurate statistics
|
|
337
|
+
try:
|
|
338
|
+
if staged:
|
|
339
|
+
stat_output = repo.git.diff("--cached", "--numstat") if repo.head.is_valid() else ""
|
|
340
|
+
else:
|
|
341
|
+
stat_output = repo.git.diff("--numstat")
|
|
342
|
+
except git.GitCommandError:
|
|
343
|
+
stat_output = ""
|
|
344
|
+
|
|
345
|
+
changed_files: list[FileChange] = []
|
|
346
|
+
total_insertions = 0
|
|
347
|
+
total_deletions = 0
|
|
348
|
+
|
|
349
|
+
for line in stat_output.strip().split("\n"):
|
|
350
|
+
if not line.strip():
|
|
351
|
+
continue
|
|
352
|
+
parts = line.split("\t")
|
|
353
|
+
if len(parts) >= 3:
|
|
354
|
+
ins_str, del_str, file_path = parts[0], parts[1], parts[2]
|
|
355
|
+
ins = int(ins_str) if ins_str != "-" else 0
|
|
356
|
+
dels = int(del_str) if del_str != "-" else 0
|
|
357
|
+
total_insertions += ins
|
|
358
|
+
total_deletions += dels
|
|
359
|
+
|
|
360
|
+
# Extract per-file section from diff for accurate change type detection
|
|
361
|
+
file_section_match = re.search(
|
|
362
|
+
rf"diff --git a/.*? b/{re.escape(file_path)}\n(.*?)(?=diff --git|\Z)",
|
|
363
|
+
diff_text,
|
|
364
|
+
re.DOTALL,
|
|
365
|
+
)
|
|
366
|
+
file_section = file_section_match.group(0) if file_section_match else ""
|
|
367
|
+
|
|
368
|
+
change_type = "modified"
|
|
369
|
+
if "new file mode" in file_section:
|
|
370
|
+
change_type = "added"
|
|
371
|
+
elif "deleted file mode" in file_section:
|
|
372
|
+
change_type = "deleted"
|
|
373
|
+
elif "rename from" in file_section:
|
|
374
|
+
change_type = "renamed"
|
|
375
|
+
|
|
376
|
+
changed_files.append(FileChange(
|
|
377
|
+
path=file_path,
|
|
378
|
+
change_type=change_type,
|
|
379
|
+
insertions=ins,
|
|
380
|
+
deletions=dels,
|
|
381
|
+
))
|
|
382
|
+
|
|
383
|
+
return DiffStats(
|
|
384
|
+
diff=diff_text,
|
|
385
|
+
files_changed=len(changed_files),
|
|
386
|
+
insertions=total_insertions,
|
|
387
|
+
deletions=total_deletions,
|
|
388
|
+
changed_files=changed_files,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def get_patch(workspace: Workspace, staged: bool = False) -> str:
|
|
393
|
+
"""Get patch-formatted diff for export.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
workspace: Target workspace
|
|
397
|
+
staged: If True, show staged changes; if False, show unstaged
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
Patch content as string (with full headers for git apply)
|
|
401
|
+
"""
|
|
402
|
+
repo = _get_repo(workspace)
|
|
403
|
+
|
|
404
|
+
try:
|
|
405
|
+
if staged:
|
|
406
|
+
if repo.head.is_valid():
|
|
407
|
+
return repo.git.diff("--cached", "--patch", "--full-index")
|
|
408
|
+
return ""
|
|
409
|
+
else:
|
|
410
|
+
return repo.git.diff("--patch", "--full-index")
|
|
411
|
+
except git.GitCommandError as e:
|
|
412
|
+
logger.warning(f"Failed to get patch: {e}")
|
|
413
|
+
return ""
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def generate_commit_message(workspace: Workspace, staged: bool = False) -> str:
|
|
417
|
+
"""Generate a commit message from the current diff.
|
|
418
|
+
|
|
419
|
+
Uses heuristic analysis of changed files and diff content to suggest
|
|
420
|
+
a conventional commit message. Does not require LLM.
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
workspace: Target workspace
|
|
424
|
+
staged: If True, analyze staged changes; if False, unstaged
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
Suggested commit message string
|
|
428
|
+
"""
|
|
429
|
+
stats = get_diff_stats(workspace, staged=staged)
|
|
430
|
+
|
|
431
|
+
if not stats.changed_files:
|
|
432
|
+
return ""
|
|
433
|
+
|
|
434
|
+
files = stats.changed_files
|
|
435
|
+
file_count = len(files)
|
|
436
|
+
|
|
437
|
+
# Determine primary action from change types
|
|
438
|
+
added = [f for f in files if f.change_type == "added"]
|
|
439
|
+
deleted = [f for f in files if f.change_type == "deleted"]
|
|
440
|
+
modified = [f for f in files if f.change_type == "modified"]
|
|
441
|
+
|
|
442
|
+
# Pick prefix based on dominant change type
|
|
443
|
+
if len(added) > len(modified) and len(added) > len(deleted):
|
|
444
|
+
prefix = "feat"
|
|
445
|
+
action = "add"
|
|
446
|
+
elif len(deleted) > len(modified):
|
|
447
|
+
prefix = "refactor"
|
|
448
|
+
action = "remove"
|
|
449
|
+
else:
|
|
450
|
+
prefix = "feat"
|
|
451
|
+
action = "update"
|
|
452
|
+
|
|
453
|
+
# Detect common patterns
|
|
454
|
+
test_files = [f for f in files if "test" in f.path.lower()]
|
|
455
|
+
if test_files and len(test_files) == file_count:
|
|
456
|
+
prefix = "test"
|
|
457
|
+
action = "add" if added else "update"
|
|
458
|
+
|
|
459
|
+
config_files = [f for f in files if f.path.endswith((".json", ".yaml", ".yml", ".toml", ".cfg", ".ini"))]
|
|
460
|
+
if config_files and len(config_files) == file_count:
|
|
461
|
+
prefix = "chore"
|
|
462
|
+
action = "update"
|
|
463
|
+
|
|
464
|
+
# Build description
|
|
465
|
+
if file_count == 1:
|
|
466
|
+
file_path = files[0].path
|
|
467
|
+
name = Path(file_path).stem
|
|
468
|
+
description = f"{action} {name}"
|
|
469
|
+
else:
|
|
470
|
+
# Find common directory
|
|
471
|
+
dirs = set(str(Path(f.path).parent) for f in files)
|
|
472
|
+
if len(dirs) == 1 and list(dirs)[0] != ".":
|
|
473
|
+
description = f"{action} {list(dirs)[0]} ({file_count} files)"
|
|
474
|
+
else:
|
|
475
|
+
description = f"{action} {file_count} files"
|
|
476
|
+
|
|
477
|
+
return f"{prefix}: {description}"
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""GitHub repository connection validation (issue #563).
|
|
2
|
+
|
|
3
|
+
Headless service used by the Integrations settings endpoints to validate a
|
|
4
|
+
Personal Access Token (PAT) against a target ``owner/repo`` before storing the
|
|
5
|
+
credential. Verifies that:
|
|
6
|
+
|
|
7
|
+
1. The token authenticates (not 401).
|
|
8
|
+
2. The repository exists and is visible to the token (not 404).
|
|
9
|
+
3. The token can read the repository's issues (not 403) — issues read is the
|
|
10
|
+
prerequisite for the later import feature.
|
|
11
|
+
|
|
12
|
+
No FastAPI / HTTP-framework imports (architecture rule #1 — core is headless).
|
|
13
|
+
Uses ``httpx.AsyncClient`` consistent with ``codeframe/git/github_integration.py``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
GITHUB_API_BASE = "https://api.github.com"
|
|
25
|
+
_TIMEOUT = 15.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GitHubConnectError(Exception):
|
|
29
|
+
"""Base class for GitHub connection validation failures."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class InvalidTokenError(GitHubConnectError):
|
|
33
|
+
"""The PAT was rejected by GitHub (401)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RepoNotFoundError(GitHubConnectError):
|
|
37
|
+
"""The repository does not exist or is not visible to the token (404)."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class InsufficientScopeError(GitHubConnectError):
|
|
41
|
+
"""The token cannot read the repository's issues (403)."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_repo(repo: str) -> tuple[str, str]:
|
|
45
|
+
"""Parse and validate an ``owner/repo`` string.
|
|
46
|
+
|
|
47
|
+
Tolerates surrounding/inner whitespace (``" acme / app "`` -> ``("acme",
|
|
48
|
+
"app")``) but requires exactly one slash with non-empty owner and name.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
ValueError: if the format is not a valid ``owner/repo``.
|
|
52
|
+
"""
|
|
53
|
+
if not repo or "/" not in repo:
|
|
54
|
+
raise ValueError(f"Invalid repository format: {repo!r}. Expected 'owner/repo'.")
|
|
55
|
+
# Reject pasted URLs (e.g. "https://github.com/acme/app" or "github.com/acme")
|
|
56
|
+
# which would otherwise parse to a bogus owner.
|
|
57
|
+
if "://" in repo or repo.strip().lower().startswith("http"):
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"Invalid repository format: {repo!r}. Expected 'owner/repo', not a URL."
|
|
60
|
+
)
|
|
61
|
+
parts = repo.split("/")
|
|
62
|
+
if len(parts) != 2:
|
|
63
|
+
raise ValueError(f"Invalid repository format: {repo!r}. Expected 'owner/repo'.")
|
|
64
|
+
owner, name = parts[0].strip(), parts[1].strip()
|
|
65
|
+
if not owner or not name:
|
|
66
|
+
raise ValueError(f"Invalid repository format: {repo!r}. Expected 'owner/repo'.")
|
|
67
|
+
# GitHub owners (users/orgs) cannot contain dots — a dotted owner means the
|
|
68
|
+
# user pasted a host like "github.com/acme". Repo names MAY contain dots.
|
|
69
|
+
if "." in owner:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"Invalid repository format: {repo!r}. "
|
|
72
|
+
"Expected 'owner/repo' (did you paste a URL?)."
|
|
73
|
+
)
|
|
74
|
+
return owner, name
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _headers(pat: str) -> dict[str, str]:
|
|
78
|
+
return {
|
|
79
|
+
"Authorization": f"Bearer {pat}",
|
|
80
|
+
"Accept": "application/vnd.github+json",
|
|
81
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
82
|
+
"User-Agent": "codeframe-integration-connect",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def validate_connection(
|
|
87
|
+
pat: str,
|
|
88
|
+
repo: str,
|
|
89
|
+
*,
|
|
90
|
+
client: httpx.AsyncClient | None = None,
|
|
91
|
+
) -> dict[str, str]:
|
|
92
|
+
"""Validate a PAT against a target repository and verify issues-read access.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
pat: GitHub Personal Access Token.
|
|
96
|
+
repo: Repository in ``owner/repo`` format.
|
|
97
|
+
client: Optional httpx client (injected by tests). When ``None`` a
|
|
98
|
+
short-lived client is created and closed internally.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
``{"repo_full_name", "owner_login", "owner_avatar_url"}`` on success.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
ValueError: if ``repo`` is not a valid ``owner/repo`` string.
|
|
105
|
+
InvalidTokenError: GitHub returned 401.
|
|
106
|
+
RepoNotFoundError: GitHub returned 404 for the repo.
|
|
107
|
+
InsufficientScopeError: the token cannot read issues (403).
|
|
108
|
+
GitHubConnectError: any other non-success response or network error.
|
|
109
|
+
"""
|
|
110
|
+
owner, name = parse_repo(repo)
|
|
111
|
+
|
|
112
|
+
own_client = client is None
|
|
113
|
+
if own_client:
|
|
114
|
+
client = httpx.AsyncClient(timeout=_TIMEOUT)
|
|
115
|
+
try:
|
|
116
|
+
headers = _headers(pat)
|
|
117
|
+
try:
|
|
118
|
+
repo_resp = await client.get(
|
|
119
|
+
f"{GITHUB_API_BASE}/repos/{owner}/{name}", headers=headers
|
|
120
|
+
)
|
|
121
|
+
except httpx.HTTPError as exc:
|
|
122
|
+
# Log only the exception class — an httpx error's str() can embed the
|
|
123
|
+
# request (URL/headers, hence the PAT) depending on version/config.
|
|
124
|
+
logger.warning("GitHub repo lookup failed: %s", type(exc).__name__)
|
|
125
|
+
raise GitHubConnectError("Could not reach GitHub. Try again later.")
|
|
126
|
+
|
|
127
|
+
if repo_resp.status_code == 401:
|
|
128
|
+
raise InvalidTokenError("Invalid GitHub token.")
|
|
129
|
+
if repo_resp.status_code == 404:
|
|
130
|
+
raise RepoNotFoundError(f"Repository '{owner}/{name}' not found.")
|
|
131
|
+
if repo_resp.status_code == 403:
|
|
132
|
+
# Repo-level 403 means the token can't even see the repo metadata.
|
|
133
|
+
raise InsufficientScopeError(
|
|
134
|
+
"Token lacks access to this repository."
|
|
135
|
+
)
|
|
136
|
+
if repo_resp.status_code >= 400:
|
|
137
|
+
raise GitHubConnectError(
|
|
138
|
+
f"GitHub returned status {repo_resp.status_code}."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
repo_data = repo_resp.json()
|
|
142
|
+
owner_data = repo_data.get("owner") or {}
|
|
143
|
+
owner_login = owner_data.get("login") or owner
|
|
144
|
+
owner_avatar_url = owner_data.get("avatar_url") or ""
|
|
145
|
+
repo_full_name = repo_data.get("full_name") or f"{owner}/{name}"
|
|
146
|
+
|
|
147
|
+
# Verify issues-read access — the prerequisite for import.
|
|
148
|
+
try:
|
|
149
|
+
issues_resp = await client.get(
|
|
150
|
+
f"{GITHUB_API_BASE}/repos/{owner}/{name}/issues",
|
|
151
|
+
params={"per_page": 1},
|
|
152
|
+
headers=headers,
|
|
153
|
+
)
|
|
154
|
+
except httpx.HTTPError as exc:
|
|
155
|
+
logger.warning("GitHub issues check failed: %s", type(exc).__name__)
|
|
156
|
+
raise GitHubConnectError("Could not reach GitHub. Try again later.")
|
|
157
|
+
|
|
158
|
+
# 410 Gone == issues are disabled on the repo. The connection is still
|
|
159
|
+
# valid; there is simply nothing to import. Anything else 4xx (notably
|
|
160
|
+
# 403) means the token cannot read issues.
|
|
161
|
+
if issues_resp.status_code == 403:
|
|
162
|
+
raise InsufficientScopeError(
|
|
163
|
+
"Token cannot read issues for this repository "
|
|
164
|
+
"(missing issues:read scope)."
|
|
165
|
+
)
|
|
166
|
+
if issues_resp.status_code >= 400 and issues_resp.status_code != 410:
|
|
167
|
+
raise GitHubConnectError(
|
|
168
|
+
f"GitHub issues check returned status {issues_resp.status_code}."
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
"repo_full_name": repo_full_name,
|
|
173
|
+
"owner_login": owner_login,
|
|
174
|
+
"owner_avatar_url": owner_avatar_url,
|
|
175
|
+
}
|
|
176
|
+
finally:
|
|
177
|
+
if own_client:
|
|
178
|
+
await client.aclose()
|