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,211 @@
|
|
|
1
|
+
"""Core business logic for API key management.
|
|
2
|
+
|
|
3
|
+
This service is used by both CLI commands and REST API endpoints,
|
|
4
|
+
ensuring consistent behavior across interfaces.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
from codeframe.auth.api_keys import (
|
|
13
|
+
generate_api_key,
|
|
14
|
+
validate_scopes,
|
|
15
|
+
SCOPE_READ,
|
|
16
|
+
SCOPE_WRITE,
|
|
17
|
+
)
|
|
18
|
+
from codeframe.platform_store.database import Database
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ApiKeyInfo:
|
|
25
|
+
"""API key information (without sensitive data)."""
|
|
26
|
+
|
|
27
|
+
id: str
|
|
28
|
+
name: str
|
|
29
|
+
prefix: str
|
|
30
|
+
scopes: List[str]
|
|
31
|
+
created_at: str
|
|
32
|
+
last_used_at: Optional[str]
|
|
33
|
+
expires_at: Optional[str]
|
|
34
|
+
is_active: bool
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class CreatedApiKey:
|
|
39
|
+
"""Result of creating an API key (includes full key shown once)."""
|
|
40
|
+
|
|
41
|
+
key: str # Full key - shown only once
|
|
42
|
+
id: str
|
|
43
|
+
prefix: str
|
|
44
|
+
created_at: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ApiKeyService:
|
|
48
|
+
"""Service for API key operations.
|
|
49
|
+
|
|
50
|
+
Encapsulates business logic for creating, listing, and revoking API keys.
|
|
51
|
+
Used by both CLI and REST API to ensure consistent behavior.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, db: Database):
|
|
55
|
+
"""Initialize with database connection.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
db: Initialized Database instance
|
|
59
|
+
"""
|
|
60
|
+
self.db = db
|
|
61
|
+
|
|
62
|
+
def create_api_key(
|
|
63
|
+
self,
|
|
64
|
+
user_id: int,
|
|
65
|
+
name: str,
|
|
66
|
+
scopes: Optional[List[str]] = None,
|
|
67
|
+
expires_at: Optional[datetime] = None,
|
|
68
|
+
) -> CreatedApiKey:
|
|
69
|
+
"""Create a new API key for a user.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
user_id: Owner user ID
|
|
73
|
+
name: Human-readable name for the key
|
|
74
|
+
scopes: Permission scopes (defaults to read, write)
|
|
75
|
+
expires_at: Optional expiration timestamp
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
CreatedApiKey with the full key (shown only once)
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
ValueError: If scopes are invalid
|
|
82
|
+
"""
|
|
83
|
+
if scopes is None:
|
|
84
|
+
scopes = [SCOPE_READ, SCOPE_WRITE]
|
|
85
|
+
|
|
86
|
+
if not validate_scopes(scopes):
|
|
87
|
+
raise ValueError(f"Invalid scopes: {scopes}. Valid scopes: read, write, admin")
|
|
88
|
+
|
|
89
|
+
# Generate the key
|
|
90
|
+
full_key, key_hash, prefix = generate_api_key()
|
|
91
|
+
|
|
92
|
+
# Store in database
|
|
93
|
+
key_id = self.db.api_keys.create(
|
|
94
|
+
user_id=user_id,
|
|
95
|
+
name=name,
|
|
96
|
+
key_hash=key_hash,
|
|
97
|
+
prefix=prefix,
|
|
98
|
+
scopes=scopes,
|
|
99
|
+
expires_at=expires_at,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
logger.info(f"API key created for user {user_id}: {prefix}...")
|
|
103
|
+
|
|
104
|
+
return CreatedApiKey(
|
|
105
|
+
key=full_key,
|
|
106
|
+
id=key_id,
|
|
107
|
+
prefix=prefix,
|
|
108
|
+
created_at=datetime.now(timezone.utc).isoformat(),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def list_api_keys(self, user_id: int) -> List[ApiKeyInfo]:
|
|
112
|
+
"""List all API keys for a user.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
user_id: The user's ID
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
List of API key info (without hashes)
|
|
119
|
+
"""
|
|
120
|
+
keys = self.db.api_keys.list_user_keys(user_id=user_id)
|
|
121
|
+
|
|
122
|
+
return [
|
|
123
|
+
ApiKeyInfo(
|
|
124
|
+
id=k["id"],
|
|
125
|
+
name=k["name"],
|
|
126
|
+
prefix=k["prefix"],
|
|
127
|
+
scopes=k["scopes"],
|
|
128
|
+
created_at=k["created_at"],
|
|
129
|
+
last_used_at=k.get("last_used_at"),
|
|
130
|
+
expires_at=k.get("expires_at"),
|
|
131
|
+
is_active=k["is_active"],
|
|
132
|
+
)
|
|
133
|
+
for k in keys
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
def revoke_api_key(self, key_id: str, user_id: int) -> bool:
|
|
137
|
+
"""Revoke an API key.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
key_id: The API key ID to revoke
|
|
141
|
+
user_id: The user attempting to revoke (must be owner)
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
True if revoked, False if not found or not owned
|
|
145
|
+
"""
|
|
146
|
+
success = self.db.api_keys.revoke(key_id, user_id=user_id)
|
|
147
|
+
|
|
148
|
+
if success:
|
|
149
|
+
logger.info(f"API key revoked by user {user_id}: {key_id}")
|
|
150
|
+
|
|
151
|
+
return success
|
|
152
|
+
|
|
153
|
+
def rotate_api_key(self, key_id: str, user_id: int) -> Optional[CreatedApiKey]:
|
|
154
|
+
"""Rotate an API key (create new, then revoke old).
|
|
155
|
+
|
|
156
|
+
Creates the new key first to ensure the user always has a working key.
|
|
157
|
+
If creation fails, the old key remains active.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
key_id: The API key ID to rotate
|
|
161
|
+
user_id: The user attempting to rotate (must be owner)
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
New CreatedApiKey, or None if old key not found/owned
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
ValueError: If new key creation fails (old key remains active)
|
|
168
|
+
"""
|
|
169
|
+
# Get the old key's configuration
|
|
170
|
+
old_key = self.db.api_keys.get_by_id(key_id)
|
|
171
|
+
if old_key is None or old_key["user_id"] != user_id:
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
# Create new key first - if this fails, old key stays active
|
|
175
|
+
new_key = self.create_api_key(
|
|
176
|
+
user_id=user_id,
|
|
177
|
+
name=old_key["name"],
|
|
178
|
+
scopes=old_key["scopes"],
|
|
179
|
+
expires_at=None, # New key starts fresh
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Now revoke the old key (brief window where both are active)
|
|
183
|
+
self.db.api_keys.revoke(key_id, user_id=user_id)
|
|
184
|
+
|
|
185
|
+
logger.info(f"API key rotated by user {user_id}: {key_id} -> {new_key.id}")
|
|
186
|
+
|
|
187
|
+
return new_key
|
|
188
|
+
|
|
189
|
+
def get_api_key(self, key_id: str) -> Optional[ApiKeyInfo]:
|
|
190
|
+
"""Get a single API key's info.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
key_id: The API key ID
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
ApiKeyInfo or None if not found
|
|
197
|
+
"""
|
|
198
|
+
key = self.db.api_keys.get_by_id(key_id)
|
|
199
|
+
if key is None:
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
return ApiKeyInfo(
|
|
203
|
+
id=key["id"],
|
|
204
|
+
name=key["name"],
|
|
205
|
+
prefix=key["prefix"],
|
|
206
|
+
scopes=key["scopes"],
|
|
207
|
+
created_at=key["created_at"],
|
|
208
|
+
last_used_at=key.get("last_used_at"),
|
|
209
|
+
expires_at=key.get("expires_at"),
|
|
210
|
+
is_active=key["is_active"],
|
|
211
|
+
)
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""Artifact management for CodeFRAME v2.
|
|
2
|
+
|
|
3
|
+
Handles patch export and git commit operations.
|
|
4
|
+
|
|
5
|
+
This module is headless - no FastAPI or HTTP dependencies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
import shutil
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from codeframe.core.workspace import Workspace
|
|
16
|
+
from codeframe.core import events
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _utc_now() -> datetime:
|
|
20
|
+
"""Get current UTC time as timezone-aware datetime."""
|
|
21
|
+
return datetime.now(timezone.utc)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class PatchInfo:
|
|
26
|
+
"""Information about an exported patch.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
path: Path to the patch file
|
|
30
|
+
size_bytes: Size of the patch file
|
|
31
|
+
files_changed: Number of files in the patch
|
|
32
|
+
insertions: Lines added
|
|
33
|
+
deletions: Lines removed
|
|
34
|
+
created_at: When the patch was created
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
path: Path
|
|
38
|
+
size_bytes: int
|
|
39
|
+
files_changed: int
|
|
40
|
+
insertions: int
|
|
41
|
+
deletions: int
|
|
42
|
+
created_at: datetime
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class CommitInfo:
|
|
47
|
+
"""Information about a created commit.
|
|
48
|
+
|
|
49
|
+
Attributes:
|
|
50
|
+
hash: Commit hash (short)
|
|
51
|
+
full_hash: Full commit hash
|
|
52
|
+
message: Commit message
|
|
53
|
+
files_changed: Number of files changed
|
|
54
|
+
insertions: Lines added
|
|
55
|
+
deletions: Lines removed
|
|
56
|
+
created_at: When the commit was created
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
hash: str
|
|
60
|
+
full_hash: str
|
|
61
|
+
message: str
|
|
62
|
+
files_changed: int
|
|
63
|
+
insertions: int
|
|
64
|
+
deletions: int
|
|
65
|
+
created_at: datetime
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def export_patch(
|
|
69
|
+
workspace: Workspace,
|
|
70
|
+
out_path: Optional[Path] = None,
|
|
71
|
+
staged_only: bool = False,
|
|
72
|
+
) -> PatchInfo:
|
|
73
|
+
"""Export changes as a patch file.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
workspace: Target workspace
|
|
77
|
+
out_path: Output path (auto-generated if None)
|
|
78
|
+
staged_only: Only include staged changes
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
PatchInfo with patch details
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
ValueError: If no changes to export or git not available
|
|
85
|
+
"""
|
|
86
|
+
repo_path = workspace.repo_path
|
|
87
|
+
|
|
88
|
+
# Check git is available
|
|
89
|
+
if not shutil.which("git"):
|
|
90
|
+
raise ValueError("git not found in PATH")
|
|
91
|
+
|
|
92
|
+
# Check we're in a git repo
|
|
93
|
+
result = subprocess.run(
|
|
94
|
+
["git", "rev-parse", "--git-dir"],
|
|
95
|
+
cwd=repo_path,
|
|
96
|
+
capture_output=True,
|
|
97
|
+
text=True,
|
|
98
|
+
)
|
|
99
|
+
if result.returncode != 0:
|
|
100
|
+
raise ValueError(f"Not a git repository: {repo_path}")
|
|
101
|
+
|
|
102
|
+
# Generate patch content
|
|
103
|
+
# Track which diff was actually used for stats calculation
|
|
104
|
+
actual_staged_used = staged_only
|
|
105
|
+
|
|
106
|
+
if staged_only:
|
|
107
|
+
diff_cmd = ["git", "diff", "--cached"]
|
|
108
|
+
else:
|
|
109
|
+
diff_cmd = ["git", "diff", "HEAD"]
|
|
110
|
+
|
|
111
|
+
result = subprocess.run(
|
|
112
|
+
diff_cmd,
|
|
113
|
+
cwd=repo_path,
|
|
114
|
+
capture_output=True,
|
|
115
|
+
text=True,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
patch_content = result.stdout
|
|
119
|
+
|
|
120
|
+
# Track if we fell back to plain unstaged diff (git diff without HEAD)
|
|
121
|
+
fell_back_to_plain_unstaged = False
|
|
122
|
+
|
|
123
|
+
if not patch_content.strip():
|
|
124
|
+
# Try just unstaged changes (git diff without HEAD)
|
|
125
|
+
result = subprocess.run(
|
|
126
|
+
["git", "diff"],
|
|
127
|
+
cwd=repo_path,
|
|
128
|
+
capture_output=True,
|
|
129
|
+
text=True,
|
|
130
|
+
)
|
|
131
|
+
patch_content = result.stdout
|
|
132
|
+
# We fell back to unstaged diff, so update the tracking flag
|
|
133
|
+
actual_staged_used = False
|
|
134
|
+
fell_back_to_plain_unstaged = True
|
|
135
|
+
|
|
136
|
+
if not patch_content.strip():
|
|
137
|
+
raise ValueError("No changes to export")
|
|
138
|
+
|
|
139
|
+
# Generate output path if not provided
|
|
140
|
+
if out_path is None:
|
|
141
|
+
timestamp = _utc_now().strftime("%Y%m%d-%H%M%S")
|
|
142
|
+
out_path = repo_path / f".codeframe/patches/patch-{timestamp}.patch"
|
|
143
|
+
|
|
144
|
+
# Ensure parent directory exists
|
|
145
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
|
|
147
|
+
# Write patch
|
|
148
|
+
out_path.write_text(patch_content)
|
|
149
|
+
|
|
150
|
+
# Get stats - when fell back to plain unstaged, parse from content directly
|
|
151
|
+
# because _get_diff_stats runs "git diff HEAD --stat" which may return zeros
|
|
152
|
+
if fell_back_to_plain_unstaged:
|
|
153
|
+
stats = _parse_patch_content_stats(patch_content)
|
|
154
|
+
else:
|
|
155
|
+
stats = _get_diff_stats(repo_path, actual_staged_used)
|
|
156
|
+
|
|
157
|
+
patch_info = PatchInfo(
|
|
158
|
+
path=out_path,
|
|
159
|
+
size_bytes=out_path.stat().st_size,
|
|
160
|
+
files_changed=stats["files"],
|
|
161
|
+
insertions=stats["insertions"],
|
|
162
|
+
deletions=stats["deletions"],
|
|
163
|
+
created_at=_utc_now(),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Emit event
|
|
167
|
+
events.emit_for_workspace(
|
|
168
|
+
workspace,
|
|
169
|
+
events.EventType.PATCH_EXPORTED,
|
|
170
|
+
{
|
|
171
|
+
"path": str(out_path),
|
|
172
|
+
"size": patch_info.size_bytes,
|
|
173
|
+
"files": patch_info.files_changed,
|
|
174
|
+
},
|
|
175
|
+
print_event=True,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return patch_info
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def create_commit(
|
|
182
|
+
workspace: Workspace,
|
|
183
|
+
message: str,
|
|
184
|
+
add_all: bool = False,
|
|
185
|
+
) -> CommitInfo:
|
|
186
|
+
"""Create a git commit.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
workspace: Target workspace
|
|
190
|
+
message: Commit message
|
|
191
|
+
add_all: Stage all changes before committing
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
CommitInfo with commit details
|
|
195
|
+
|
|
196
|
+
Raises:
|
|
197
|
+
ValueError: If commit fails or nothing to commit
|
|
198
|
+
"""
|
|
199
|
+
repo_path = workspace.repo_path
|
|
200
|
+
|
|
201
|
+
# Check git is available
|
|
202
|
+
if not shutil.which("git"):
|
|
203
|
+
raise ValueError("git not found in PATH")
|
|
204
|
+
|
|
205
|
+
# Optionally stage all changes
|
|
206
|
+
if add_all:
|
|
207
|
+
result = subprocess.run(
|
|
208
|
+
["git", "add", "-A"],
|
|
209
|
+
cwd=repo_path,
|
|
210
|
+
capture_output=True,
|
|
211
|
+
text=True,
|
|
212
|
+
)
|
|
213
|
+
if result.returncode != 0:
|
|
214
|
+
raise ValueError(f"git add failed: {result.stderr}")
|
|
215
|
+
|
|
216
|
+
# Check there are staged changes
|
|
217
|
+
result = subprocess.run(
|
|
218
|
+
["git", "diff", "--cached", "--stat"],
|
|
219
|
+
cwd=repo_path,
|
|
220
|
+
capture_output=True,
|
|
221
|
+
text=True,
|
|
222
|
+
)
|
|
223
|
+
if not result.stdout.strip():
|
|
224
|
+
raise ValueError("No staged changes to commit")
|
|
225
|
+
|
|
226
|
+
# Get stats before commit
|
|
227
|
+
stats = _get_diff_stats(repo_path, staged_only=True)
|
|
228
|
+
|
|
229
|
+
# Create commit
|
|
230
|
+
result = subprocess.run(
|
|
231
|
+
["git", "commit", "-m", message],
|
|
232
|
+
cwd=repo_path,
|
|
233
|
+
capture_output=True,
|
|
234
|
+
text=True,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if result.returncode != 0:
|
|
238
|
+
raise ValueError(f"git commit failed: {result.stderr}")
|
|
239
|
+
|
|
240
|
+
# Get commit hash
|
|
241
|
+
result = subprocess.run(
|
|
242
|
+
["git", "rev-parse", "HEAD"],
|
|
243
|
+
cwd=repo_path,
|
|
244
|
+
capture_output=True,
|
|
245
|
+
text=True,
|
|
246
|
+
)
|
|
247
|
+
full_hash = result.stdout.strip()
|
|
248
|
+
short_hash = full_hash[:7]
|
|
249
|
+
|
|
250
|
+
commit_info = CommitInfo(
|
|
251
|
+
hash=short_hash,
|
|
252
|
+
full_hash=full_hash,
|
|
253
|
+
message=message,
|
|
254
|
+
files_changed=stats["files"],
|
|
255
|
+
insertions=stats["insertions"],
|
|
256
|
+
deletions=stats["deletions"],
|
|
257
|
+
created_at=_utc_now(),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Emit event
|
|
261
|
+
events.emit_for_workspace(
|
|
262
|
+
workspace,
|
|
263
|
+
events.EventType.COMMIT_CREATED,
|
|
264
|
+
{
|
|
265
|
+
"hash": short_hash,
|
|
266
|
+
"message": message[:100],
|
|
267
|
+
"files": commit_info.files_changed,
|
|
268
|
+
},
|
|
269
|
+
print_event=True,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
return commit_info
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def get_status(repo_path: Path) -> dict:
|
|
276
|
+
"""Get git status summary.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
repo_path: Repository path
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Dict with status info
|
|
283
|
+
"""
|
|
284
|
+
result = subprocess.run(
|
|
285
|
+
["git", "status", "--porcelain"],
|
|
286
|
+
cwd=repo_path,
|
|
287
|
+
capture_output=True,
|
|
288
|
+
text=True,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
lines = [line for line in result.stdout.strip().split("\n") if line]
|
|
292
|
+
|
|
293
|
+
staged = []
|
|
294
|
+
unstaged = []
|
|
295
|
+
untracked = []
|
|
296
|
+
|
|
297
|
+
for line in lines:
|
|
298
|
+
if len(line) < 3:
|
|
299
|
+
continue
|
|
300
|
+
index_status = line[0]
|
|
301
|
+
worktree_status = line[1]
|
|
302
|
+
filename = line[3:]
|
|
303
|
+
|
|
304
|
+
if index_status == "?":
|
|
305
|
+
untracked.append(filename)
|
|
306
|
+
elif index_status != " ":
|
|
307
|
+
staged.append(filename)
|
|
308
|
+
if worktree_status != " " and worktree_status != "?":
|
|
309
|
+
unstaged.append(filename)
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
"staged": staged,
|
|
313
|
+
"unstaged": unstaged,
|
|
314
|
+
"untracked": untracked,
|
|
315
|
+
"clean": len(lines) == 0,
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def list_patches(workspace: Workspace) -> list[PatchInfo]:
|
|
320
|
+
"""List exported patches.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
workspace: Target workspace
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
List of PatchInfo, newest first
|
|
327
|
+
"""
|
|
328
|
+
patches_dir = workspace.state_dir / "patches"
|
|
329
|
+
if not patches_dir.exists():
|
|
330
|
+
return []
|
|
331
|
+
|
|
332
|
+
patches = []
|
|
333
|
+
for patch_file in sorted(patches_dir.glob("*.patch"), reverse=True):
|
|
334
|
+
stats = _parse_patch_stats(patch_file)
|
|
335
|
+
patches.append(
|
|
336
|
+
PatchInfo(
|
|
337
|
+
path=patch_file,
|
|
338
|
+
size_bytes=patch_file.stat().st_size,
|
|
339
|
+
files_changed=stats.get("files", 0),
|
|
340
|
+
insertions=stats.get("insertions", 0),
|
|
341
|
+
deletions=stats.get("deletions", 0),
|
|
342
|
+
created_at=datetime.fromtimestamp(
|
|
343
|
+
patch_file.stat().st_mtime, tz=timezone.utc
|
|
344
|
+
),
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
return patches
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _get_diff_stats(repo_path: Path, staged_only: bool = False) -> dict:
|
|
352
|
+
"""Get diff statistics."""
|
|
353
|
+
if staged_only:
|
|
354
|
+
cmd = ["git", "diff", "--cached", "--stat"]
|
|
355
|
+
else:
|
|
356
|
+
cmd = ["git", "diff", "HEAD", "--stat"]
|
|
357
|
+
|
|
358
|
+
result = subprocess.run(
|
|
359
|
+
cmd,
|
|
360
|
+
cwd=repo_path,
|
|
361
|
+
capture_output=True,
|
|
362
|
+
text=True,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# Parse the last line which has summary
|
|
366
|
+
lines = result.stdout.strip().split("\n")
|
|
367
|
+
if not lines or not lines[-1]:
|
|
368
|
+
return {"files": 0, "insertions": 0, "deletions": 0}
|
|
369
|
+
|
|
370
|
+
summary = lines[-1]
|
|
371
|
+
|
|
372
|
+
# Parse "X files changed, Y insertions(+), Z deletions(-)"
|
|
373
|
+
files = 0
|
|
374
|
+
insertions = 0
|
|
375
|
+
deletions = 0
|
|
376
|
+
|
|
377
|
+
import re
|
|
378
|
+
|
|
379
|
+
files_match = re.search(r"(\d+) files? changed", summary)
|
|
380
|
+
if files_match:
|
|
381
|
+
files = int(files_match.group(1))
|
|
382
|
+
|
|
383
|
+
ins_match = re.search(r"(\d+) insertions?\(\+\)", summary)
|
|
384
|
+
if ins_match:
|
|
385
|
+
insertions = int(ins_match.group(1))
|
|
386
|
+
|
|
387
|
+
del_match = re.search(r"(\d+) deletions?\(-\)", summary)
|
|
388
|
+
if del_match:
|
|
389
|
+
deletions = int(del_match.group(1))
|
|
390
|
+
|
|
391
|
+
return {"files": files, "insertions": insertions, "deletions": deletions}
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _parse_patch_content_stats(content: str) -> dict:
|
|
395
|
+
"""Parse stats from patch content string.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
content: Raw patch content (git diff output)
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
Dict with files, insertions, deletions counts
|
|
402
|
+
"""
|
|
403
|
+
files = set()
|
|
404
|
+
insertions = 0
|
|
405
|
+
deletions = 0
|
|
406
|
+
|
|
407
|
+
for line in content.split("\n"):
|
|
408
|
+
if line.startswith("diff --git"):
|
|
409
|
+
# Extract filename
|
|
410
|
+
parts = line.split()
|
|
411
|
+
if len(parts) >= 4:
|
|
412
|
+
files.add(parts[3])
|
|
413
|
+
elif line.startswith("+") and not line.startswith("+++"):
|
|
414
|
+
insertions += 1
|
|
415
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
416
|
+
deletions += 1
|
|
417
|
+
|
|
418
|
+
return {
|
|
419
|
+
"files": len(files),
|
|
420
|
+
"insertions": insertions,
|
|
421
|
+
"deletions": deletions,
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _parse_patch_stats(patch_file: Path) -> dict:
|
|
426
|
+
"""Parse stats from a patch file."""
|
|
427
|
+
content = patch_file.read_text()
|
|
428
|
+
return _parse_patch_content_stats(content)
|