codeframe-ai 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,118 @@
1
+ """Per-workspace GitHub integration config (issue #563).
2
+
3
+ Stores only **non-secret** repo metadata for a connected GitHub repository under
4
+ ``.codeframe/github_integration.json``. The PAT itself is stored in the
5
+ machine-wide ``CredentialManager`` (``CredentialProvider.GIT_GITHUB``) — never
6
+ in this file.
7
+
8
+ Headless — no FastAPI or HTTP imports (architecture rule #1). Mirrors the
9
+ shape of ``codeframe/core/notifications_config.py``.
10
+
11
+ Schema (``.codeframe/github_integration.json``):
12
+
13
+ {
14
+ "repo": "owner/repo",
15
+ "owner_login": "owner",
16
+ "owner_avatar_url": "https://avatars.githubusercontent.com/...",
17
+ "connected_at": "2026-06-01T12:00:00+00:00"
18
+ }
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import logging
25
+ import os
26
+ import tempfile
27
+ from datetime import datetime, timezone
28
+ from pathlib import Path
29
+ from typing import Optional, TypedDict
30
+
31
+ from codeframe.core.workspace import Workspace
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ GITHUB_INTEGRATION_CONFIG_FILENAME = "github_integration.json"
36
+
37
+
38
+ class GitHubIntegrationConfig(TypedDict):
39
+ repo: str
40
+ owner_login: str
41
+ owner_avatar_url: str
42
+ connected_at: str
43
+
44
+
45
+ def _config_path(workspace: Workspace) -> Path:
46
+ return workspace.state_dir / GITHUB_INTEGRATION_CONFIG_FILENAME
47
+
48
+
49
+ def load_github_integration_config(
50
+ workspace: Workspace,
51
+ ) -> Optional[GitHubIntegrationConfig]:
52
+ """Read the integration config, returning ``None`` when absent or corrupt.
53
+
54
+ Never raises — a broken config should read as "not connected" rather than
55
+ breaking the status endpoint.
56
+ """
57
+ path = _config_path(workspace)
58
+ if not path.exists():
59
+ return None
60
+ try:
61
+ data = json.loads(path.read_text())
62
+ if not isinstance(data, dict) or not data.get("repo"):
63
+ raise ValueError("missing required 'repo' field")
64
+ return {
65
+ "repo": str(data["repo"]),
66
+ "owner_login": str(data.get("owner_login") or ""),
67
+ "owner_avatar_url": str(data.get("owner_avatar_url") or ""),
68
+ "connected_at": str(data.get("connected_at") or ""),
69
+ }
70
+ except (OSError, json.JSONDecodeError, ValueError) as e:
71
+ logger.warning(
72
+ "Invalid github_integration.json — treating as not connected: %s", e
73
+ )
74
+ return None
75
+
76
+
77
+ def save_github_integration_config(
78
+ workspace: Workspace,
79
+ config: dict,
80
+ ) -> GitHubIntegrationConfig:
81
+ """Atomically persist integration config to disk.
82
+
83
+ ``connected_at`` is stamped here (UTC) if not supplied by the caller.
84
+ Returns the normalized config that was written.
85
+ """
86
+ payload: GitHubIntegrationConfig = {
87
+ "repo": str(config["repo"]),
88
+ "owner_login": str(config.get("owner_login") or ""),
89
+ "owner_avatar_url": str(config.get("owner_avatar_url") or ""),
90
+ "connected_at": str(
91
+ config.get("connected_at") or datetime.now(timezone.utc).isoformat()
92
+ ),
93
+ }
94
+ path = _config_path(workspace)
95
+ path.parent.mkdir(parents=True, exist_ok=True)
96
+ fd, tmp_name = tempfile.mkstemp(
97
+ prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
98
+ )
99
+ try:
100
+ with os.fdopen(fd, "w") as f:
101
+ f.write(json.dumps(payload, indent=2))
102
+ os.replace(tmp_name, path)
103
+ except Exception:
104
+ try:
105
+ os.unlink(tmp_name)
106
+ except OSError:
107
+ pass
108
+ raise
109
+ return payload
110
+
111
+
112
+ def clear_github_integration_config(workspace: Workspace) -> None:
113
+ """Remove the integration config. Idempotent — absence is a no-op."""
114
+ path = _config_path(workspace)
115
+ try:
116
+ path.unlink(missing_ok=True)
117
+ except OSError as e:
118
+ logger.warning("Failed to remove github_integration.json: %s", e)
@@ -0,0 +1,449 @@
1
+ """GitHub open-issues listing service (issue #564).
2
+
3
+ Headless service used by the Integrations issues endpoint to fetch a connected
4
+ repository's **open** issues for the import browser UI. Builds on the connection
5
+ established in #563: the PAT comes from the machine-wide ``CredentialManager``
6
+ and the ``owner/repo`` from per-workspace ``.codeframe/github_integration.json``
7
+ — this module only performs the GitHub API call given those values.
8
+
9
+ No FastAPI / HTTP-framework imports (architecture rule #1 — core is headless).
10
+ Reuses the shared helpers and typed errors from ``github_connect_service``.
11
+
12
+ Search note: GitHub's REST *list* endpoint (``/repos/{o}/{r}/issues``) does not
13
+ support free-text search, so when a ``search`` term is supplied this routes to
14
+ ``/search/issues`` with a ``repo:`` + ``is:issue`` + ``is:open`` qualifier and
15
+ reads the authoritative ``total_count``. The plain list endpoint also returns
16
+ pull requests, which are filtered out here.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import re
23
+ from typing import Optional, TypedDict
24
+
25
+ import httpx
26
+
27
+ from codeframe.core.github_connect_service import (
28
+ GITHUB_API_BASE,
29
+ GitHubConnectError,
30
+ InsufficientScopeError,
31
+ InvalidTokenError,
32
+ _headers,
33
+ parse_repo,
34
+ )
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ _TIMEOUT = 15.0
39
+
40
+
41
+ class NotAnIssueError(Exception):
42
+ """The requested number refers to a pull request, not an issue (#565).
43
+
44
+ Intentionally NOT a ``GitHubConnectError`` subclass: callers map it to a
45
+ client error (the caller sent a PR number), not a GitHub upstream failure.
46
+ """
47
+
48
+
49
+ class IssueNotFoundError(Exception):
50
+ """The requested issue number does not exist in the repo (404) (#565).
51
+
52
+ Intentionally NOT a ``GitHubConnectError`` subclass: a missing/stale issue
53
+ number is a client error (bad payload), not a GitHub upstream failure, so
54
+ callers map it to a 4xx rather than a 502.
55
+ """
56
+
57
+ # Parse the ``page=N`` query param out of a Link header's rel="last" URL.
58
+ _LAST_PAGE_RE = re.compile(r'[?&]page=(\d+)[^>]*>;\s*rel="last"')
59
+
60
+
61
+ class GitHubIssue(TypedDict):
62
+ number: int
63
+ title: str
64
+ labels: list[str]
65
+ assignee: Optional[str]
66
+ created_at: str
67
+ html_url: str
68
+
69
+
70
+ def _simplify(raw: dict) -> GitHubIssue:
71
+ labels_raw = raw.get("labels") or []
72
+ labels = [
73
+ (lbl.get("name") if isinstance(lbl, dict) else str(lbl))
74
+ for lbl in labels_raw
75
+ ]
76
+ labels = [n for n in labels if n]
77
+ assignee_raw = raw.get("assignee") or None
78
+ assignee = assignee_raw.get("login") if isinstance(assignee_raw, dict) else None
79
+ return {
80
+ "number": int(raw.get("number", 0)),
81
+ "title": str(raw.get("title") or ""),
82
+ "labels": labels,
83
+ "assignee": assignee,
84
+ "created_at": str(raw.get("created_at") or ""),
85
+ "html_url": str(raw.get("html_url") or ""),
86
+ }
87
+
88
+
89
+ def _raise_for_status(status_code: int, *, context: str) -> None:
90
+ """Map a GitHub HTTP status to a typed error. 2xx/410 are handled by callers."""
91
+ if status_code == 401:
92
+ raise InvalidTokenError("Invalid GitHub token.")
93
+ if status_code == 403:
94
+ raise InsufficientScopeError(
95
+ "Token cannot read issues for this repository "
96
+ "(missing issues:read scope)."
97
+ )
98
+ if status_code >= 400:
99
+ raise GitHubConnectError(
100
+ f"GitHub {context} returned status {status_code}."
101
+ )
102
+
103
+
104
+ def _total_from_link_header(link: Optional[str], items_len: int, per_page: int) -> int:
105
+ """Estimate total issue count from the ``Link`` header's rel="last" page.
106
+
107
+ GitHub does not return an exact count on the list endpoint; the last-page
108
+ number times ``per_page`` is the standard upper-bound estimate used for
109
+ pagination controls. Falls back to ``items_len`` when there is no next page.
110
+ """
111
+ if link:
112
+ match = _LAST_PAGE_RE.search(link)
113
+ if match:
114
+ return int(match.group(1)) * per_page
115
+ return items_len
116
+
117
+
118
+ async def list_issues(
119
+ pat: str,
120
+ repo: str,
121
+ *,
122
+ page: int = 1,
123
+ per_page: int = 25,
124
+ search: str = "",
125
+ label: str = "",
126
+ client: Optional[httpx.AsyncClient] = None,
127
+ ) -> tuple[list[GitHubIssue], int]:
128
+ """List **open** issues for ``repo``, optionally filtered by search/label.
129
+
130
+ Args:
131
+ pat: GitHub Personal Access Token.
132
+ repo: Repository in ``owner/repo`` format.
133
+ page: 1-indexed page number.
134
+ per_page: Page size (caller should clamp to GitHub's 1..100 range).
135
+ search: Free-text title/body search (routes to the search API).
136
+ label: Single label name to filter by.
137
+ client: Optional httpx client (injected by tests). When ``None`` a
138
+ short-lived client is created and closed internally.
139
+
140
+ Returns:
141
+ ``(issues, total)`` where ``issues`` is a list of simplified open issues
142
+ (pull requests excluded) and ``total`` is the best-available count for
143
+ pagination.
144
+
145
+ Raises:
146
+ ValueError: if ``repo`` is not a valid ``owner/repo`` string.
147
+ InvalidTokenError: GitHub returned 401.
148
+ InsufficientScopeError: the token cannot read issues (403).
149
+ GitHubConnectError: any other non-success response or network error.
150
+ """
151
+ owner, name = parse_repo(repo)
152
+
153
+ own_client = client is None
154
+ if own_client:
155
+ client = httpx.AsyncClient(timeout=_TIMEOUT)
156
+ try:
157
+ headers = _headers(pat)
158
+ if search.strip():
159
+ return await _search_issues(
160
+ client, headers, owner, name, page, per_page, search, label
161
+ )
162
+ return await _list_issues(
163
+ client, headers, owner, name, page, per_page, label
164
+ )
165
+ finally:
166
+ if own_client:
167
+ await client.aclose()
168
+
169
+
170
+ async def _list_issues(
171
+ client: httpx.AsyncClient,
172
+ headers: dict[str, str],
173
+ owner: str,
174
+ name: str,
175
+ page: int,
176
+ per_page: int,
177
+ label: str,
178
+ ) -> tuple[list[GitHubIssue], int]:
179
+ params: dict[str, object] = {
180
+ "state": "open",
181
+ "page": page,
182
+ "per_page": per_page,
183
+ }
184
+ if label.strip():
185
+ params["labels"] = label.strip()
186
+ try:
187
+ resp = await client.get(
188
+ f"{GITHUB_API_BASE}/repos/{owner}/{name}/issues",
189
+ params=params,
190
+ headers=headers,
191
+ )
192
+ except httpx.HTTPError as exc:
193
+ logger.warning("GitHub issues list failed: %s", type(exc).__name__)
194
+ raise GitHubConnectError("Could not reach GitHub. Try again later.")
195
+
196
+ # 410 Gone == issues disabled on the repo: nothing to import, not an error.
197
+ if resp.status_code == 410:
198
+ return [], 0
199
+ _raise_for_status(resp.status_code, context="issues list")
200
+
201
+ raw_items = resp.json()
202
+ if not isinstance(raw_items, list):
203
+ raw_items = []
204
+ # The /issues endpoint includes pull requests — drop them.
205
+ issues = [_simplify(it) for it in raw_items if "pull_request" not in it]
206
+ total = _total_from_link_header(resp.headers.get("Link"), len(issues), per_page)
207
+ return issues, total
208
+
209
+
210
+ class GitHubIssueDetail(TypedDict):
211
+ number: int
212
+ title: str
213
+ body: str
214
+ labels: list[str]
215
+ html_url: str
216
+
217
+
218
+ async def get_issue(
219
+ pat: str,
220
+ repo: str,
221
+ number: int,
222
+ *,
223
+ client: Optional[httpx.AsyncClient] = None,
224
+ ) -> GitHubIssueDetail:
225
+ """Fetch a single issue's details for import (issue #565).
226
+
227
+ Unlike the list endpoint, this returns the issue ``body`` so the importer
228
+ can populate the task description.
229
+
230
+ Args:
231
+ pat: GitHub Personal Access Token.
232
+ repo: Repository in ``owner/repo`` format.
233
+ number: Issue number to fetch.
234
+ client: Optional httpx client (injected by tests). When ``None`` a
235
+ short-lived client is created and closed internally.
236
+
237
+ Returns:
238
+ ``{number, title, body, labels, html_url}`` — ``body`` is normalized to
239
+ ``""`` when GitHub returns null.
240
+
241
+ Raises:
242
+ ValueError: if ``repo`` is not a valid ``owner/repo`` string.
243
+ InvalidTokenError: GitHub returned 401.
244
+ InsufficientScopeError: the token cannot read issues (403).
245
+ GitHubConnectError: any other non-success response or network error.
246
+ """
247
+ owner, name = parse_repo(repo)
248
+
249
+ own_client = client is None
250
+ if own_client:
251
+ client = httpx.AsyncClient(timeout=_TIMEOUT)
252
+ try:
253
+ try:
254
+ resp = await client.get(
255
+ f"{GITHUB_API_BASE}/repos/{owner}/{name}/issues/{number}",
256
+ headers=_headers(pat),
257
+ )
258
+ except httpx.HTTPError as exc:
259
+ logger.warning("GitHub get issue failed: %s", type(exc).__name__)
260
+ raise GitHubConnectError("Could not reach GitHub. Try again later.")
261
+
262
+ # A 404 on /issues/{n} is ambiguous: the issue may genuinely not exist,
263
+ # OR the repo/token became inaccessible (renamed/deleted repo, rotated
264
+ # token). Probe the repo to tell a client typo (-> IssueNotFoundError,
265
+ # 404) apart from a broken integration (-> connect/auth error) so callers
266
+ # get the right recovery path. The probe only runs on the 404 path.
267
+ if resp.status_code == 404:
268
+ try:
269
+ repo_resp = await client.get(
270
+ f"{GITHUB_API_BASE}/repos/{owner}/{name}", headers=_headers(pat)
271
+ )
272
+ except httpx.HTTPError as exc:
273
+ logger.warning("GitHub repo probe failed: %s", type(exc).__name__)
274
+ raise GitHubConnectError("Could not reach GitHub. Try again later.")
275
+ if repo_resp.status_code == 401:
276
+ raise InvalidTokenError("Invalid GitHub token.")
277
+ if repo_resp.status_code == 403:
278
+ raise InsufficientScopeError(
279
+ "Token lacks access to this repository."
280
+ )
281
+ if repo_resp.status_code == 404:
282
+ raise GitHubConnectError(
283
+ f"Repository '{repo}' is no longer accessible."
284
+ )
285
+ if repo_resp.status_code >= 400:
286
+ # Rate limit / 5xx / other failure on the probe — a real upstream
287
+ # problem, NOT a missing issue. Surface it as such so the caller
288
+ # retries rather than blaming the issue number.
289
+ raise GitHubConnectError(
290
+ f"GitHub repo check returned status {repo_resp.status_code}."
291
+ )
292
+ # Repo probe succeeded (2xx) → the issue itself genuinely does not
293
+ # exist. (A 3xx would also land here, but GitHub answers repo lookups
294
+ # with 2xx/4xx, not redirects, for this endpoint.)
295
+ if repo_resp.status_code >= 300:
296
+ raise GitHubConnectError(
297
+ f"GitHub repo check returned status {repo_resp.status_code}."
298
+ )
299
+ raise IssueNotFoundError(f"Issue #{number} was not found in '{repo}'.")
300
+ _raise_for_status(resp.status_code, context="get issue")
301
+
302
+ raw = resp.json()
303
+ if not isinstance(raw, dict):
304
+ raw = {}
305
+ # The issues endpoint also returns pull requests (a PR is an issue with a
306
+ # ``pull_request`` member). Reject them so the import stays consistent
307
+ # with ``list_issues`` (which excludes PRs) and never links a PR as an
308
+ # issue.
309
+ if "pull_request" in raw:
310
+ raise NotAnIssueError(f"#{number} is a pull request, not an issue.")
311
+ labels_raw = raw.get("labels") or []
312
+ labels = [
313
+ (lbl.get("name") if isinstance(lbl, dict) else str(lbl))
314
+ for lbl in labels_raw
315
+ ]
316
+ labels = [n for n in labels if n]
317
+ return {
318
+ "number": int(raw.get("number", number)),
319
+ "title": str(raw.get("title") or ""),
320
+ "body": str(raw.get("body") or ""),
321
+ "labels": labels,
322
+ "html_url": str(raw.get("html_url") or ""),
323
+ }
324
+ finally:
325
+ if own_client:
326
+ await client.aclose()
327
+
328
+
329
+ async def close_issue(
330
+ pat: str,
331
+ repo: str,
332
+ number: int,
333
+ *,
334
+ comment: Optional[str] = None,
335
+ timeout: float = _TIMEOUT,
336
+ client: Optional[httpx.AsyncClient] = None,
337
+ ) -> bool:
338
+ """Close a GitHub issue, optionally posting a comment first (issue #565).
339
+
340
+ Args:
341
+ pat: GitHub Personal Access Token.
342
+ repo: Repository in ``owner/repo`` format.
343
+ number: Issue number to close.
344
+ comment: Optional comment body to post before closing.
345
+ timeout: HTTP timeout in seconds for the (self-created) client. Auto-close
346
+ passes a short value so a hung close never stalls a caller for long.
347
+ client: Optional httpx client (injected by tests). When ``None`` a
348
+ short-lived client is created and closed internally.
349
+
350
+ Returns:
351
+ ``True`` when the issue was closed.
352
+
353
+ Raises:
354
+ ValueError: if ``repo`` is not a valid ``owner/repo`` string.
355
+ InvalidTokenError: GitHub returned 401.
356
+ InsufficientScopeError: the token cannot write issues (403).
357
+ GitHubConnectError: any other non-success response or network error.
358
+ """
359
+ owner, name = parse_repo(repo)
360
+
361
+ own_client = client is None
362
+ if own_client:
363
+ client = httpx.AsyncClient(timeout=timeout)
364
+ try:
365
+ headers = _headers(pat)
366
+ base = f"{GITHUB_API_BASE}/repos/{owner}/{name}/issues/{number}"
367
+
368
+ if comment:
369
+ # Best-effort: the comment is cosmetic. A failure to post it (locked
370
+ # issue, repo with commenting disabled, transient error) must NOT
371
+ # prevent the close itself, which is the operation that matters.
372
+ try:
373
+ cresp = await client.post(
374
+ f"{base}/comments", json={"body": comment}, headers=headers
375
+ )
376
+ if cresp.status_code >= 400:
377
+ logger.warning(
378
+ "GitHub issue comment returned %s; closing anyway.",
379
+ cresp.status_code,
380
+ )
381
+ except httpx.HTTPError as exc:
382
+ logger.warning(
383
+ "GitHub issue comment failed (%s); closing anyway.",
384
+ type(exc).__name__,
385
+ )
386
+
387
+ try:
388
+ resp = await client.patch(
389
+ base, json={"state": "closed"}, headers=headers
390
+ )
391
+ except httpx.HTTPError as exc:
392
+ logger.warning("GitHub close issue failed: %s", type(exc).__name__)
393
+ raise GitHubConnectError("Could not reach GitHub. Try again later.")
394
+
395
+ _raise_for_status(resp.status_code, context="close issue")
396
+ # A redirect (3xx) — e.g. a moved/renamed/transferred repo — means the
397
+ # PATCH was NOT applied (httpx does not follow redirects by default), so
398
+ # the issue is still open. Treat it as a failure rather than reporting a
399
+ # silent success.
400
+ if resp.status_code >= 300:
401
+ raise GitHubConnectError(
402
+ f"GitHub close returned status {resp.status_code}; "
403
+ "issue was not closed (repository may have moved)."
404
+ )
405
+ return True
406
+ finally:
407
+ if own_client:
408
+ await client.aclose()
409
+
410
+
411
+ async def _search_issues(
412
+ client: httpx.AsyncClient,
413
+ headers: dict[str, str],
414
+ owner: str,
415
+ name: str,
416
+ page: int,
417
+ per_page: int,
418
+ search: str,
419
+ label: str,
420
+ ) -> tuple[list[GitHubIssue], int]:
421
+ qualifiers = [
422
+ search.strip(),
423
+ f"repo:{owner}/{name}",
424
+ "is:issue",
425
+ "is:open",
426
+ ]
427
+ if label.strip():
428
+ qualifiers.append(f'label:"{label.strip()}"')
429
+ q = " ".join(qualifiers)
430
+ try:
431
+ resp = await client.get(
432
+ f"{GITHUB_API_BASE}/search/issues",
433
+ params={"q": q, "page": page, "per_page": per_page},
434
+ headers=headers,
435
+ )
436
+ except httpx.HTTPError as exc:
437
+ logger.warning("GitHub issues search failed: %s", type(exc).__name__)
438
+ raise GitHubConnectError("Could not reach GitHub. Try again later.")
439
+
440
+ _raise_for_status(resp.status_code, context="issues search")
441
+
442
+ data = resp.json()
443
+ if not isinstance(data, dict):
444
+ data = {}
445
+ raw_items = data.get("items") or []
446
+ # The search API can still surface PRs if the qualifier is loosened; guard.
447
+ issues = [_simplify(it) for it in raw_items if "pull_request" not in it]
448
+ total = int(data.get("total_count", len(issues)))
449
+ return issues, total