codeframe-ai 0.9.0__py3-none-any.whl

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