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