arthexis 0.1.13__py3-none-any.whl → 0.1.15__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.
- {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/METADATA +224 -221
- arthexis-0.1.15.dist-info/RECORD +110 -0
- {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/licenses/LICENSE +674 -674
- config/__init__.py +5 -5
- config/active_app.py +15 -15
- config/asgi.py +43 -43
- config/auth_app.py +7 -7
- config/celery.py +32 -32
- config/context_processors.py +67 -69
- config/horologia_app.py +7 -7
- config/loadenv.py +11 -11
- config/logging.py +59 -48
- config/middleware.py +25 -25
- config/offline.py +49 -49
- config/settings.py +691 -682
- config/settings_helpers.py +109 -109
- config/urls.py +171 -166
- config/wsgi.py +17 -17
- core/admin.py +3795 -2809
- core/admin_history.py +50 -50
- core/admindocs.py +151 -151
- core/apps.py +356 -272
- core/auto_upgrade.py +57 -57
- core/backends.py +265 -236
- core/changelog.py +342 -0
- core/entity.py +149 -133
- core/environment.py +61 -61
- core/fields.py +168 -168
- core/form_fields.py +75 -75
- core/github_helper.py +188 -25
- core/github_issues.py +178 -172
- core/github_repos.py +72 -0
- core/lcd_screen.py +78 -78
- core/liveupdate.py +25 -25
- core/log_paths.py +114 -100
- core/mailer.py +85 -85
- core/middleware.py +91 -91
- core/models.py +3637 -2795
- core/notifications.py +105 -105
- core/public_wifi.py +267 -227
- core/reference_utils.py +108 -108
- core/release.py +840 -368
- core/rfid_import_export.py +113 -0
- core/sigil_builder.py +149 -149
- core/sigil_context.py +20 -20
- core/sigil_resolver.py +315 -315
- core/system.py +952 -493
- core/tasks.py +408 -394
- core/temp_passwords.py +181 -181
- core/test_system_info.py +186 -139
- core/tests.py +2168 -1521
- core/tests_liveupdate.py +17 -17
- core/urls.py +11 -11
- core/user_data.py +641 -633
- core/views.py +2201 -1417
- core/widgets.py +213 -94
- core/workgroup_urls.py +17 -17
- core/workgroup_views.py +94 -94
- nodes/admin.py +1720 -1161
- nodes/apps.py +87 -85
- nodes/backends.py +160 -160
- nodes/dns.py +203 -203
- nodes/feature_checks.py +133 -133
- nodes/lcd.py +165 -165
- nodes/models.py +1764 -1597
- nodes/reports.py +411 -411
- nodes/rfid_sync.py +195 -0
- nodes/signals.py +18 -0
- nodes/tasks.py +46 -46
- nodes/tests.py +3830 -3116
- nodes/urls.py +15 -14
- nodes/utils.py +121 -105
- nodes/views.py +683 -619
- ocpp/admin.py +948 -948
- ocpp/apps.py +25 -25
- ocpp/consumers.py +1565 -1459
- ocpp/evcs.py +844 -844
- ocpp/evcs_discovery.py +158 -158
- ocpp/models.py +917 -917
- ocpp/reference_utils.py +42 -42
- ocpp/routing.py +11 -11
- ocpp/simulator.py +745 -745
- ocpp/status_display.py +26 -26
- ocpp/store.py +601 -541
- ocpp/tasks.py +31 -31
- ocpp/test_export_import.py +130 -130
- ocpp/test_rfid.py +913 -702
- ocpp/tests.py +4445 -4094
- ocpp/transactions_io.py +189 -189
- ocpp/urls.py +50 -50
- ocpp/views.py +1479 -1251
- pages/admin.py +769 -539
- pages/apps.py +10 -10
- pages/checks.py +40 -40
- pages/context_processors.py +127 -119
- pages/defaults.py +13 -13
- pages/forms.py +198 -198
- pages/middleware.py +209 -153
- pages/models.py +643 -426
- pages/tasks.py +74 -0
- pages/tests.py +3025 -2200
- pages/urls.py +26 -25
- pages/utils.py +23 -12
- pages/views.py +1176 -1128
- arthexis-0.1.13.dist-info/RECORD +0 -105
- nodes/actions.py +0 -70
- {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/WHEEL +0 -0
- {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/top_level.txt +0 -0
core/github_helper.py
CHANGED
|
@@ -1,25 +1,188 @@
|
|
|
1
|
-
"""Helpers for reporting exceptions to GitHub."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import logging
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
from
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
1
|
+
"""Helpers for reporting exceptions to GitHub and managing repositories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from celery import shared_task
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
15
|
+
from .models import Package
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
GITHUB_API_ROOT = "https://api.github.com"
|
|
22
|
+
REQUEST_TIMEOUT = 10
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class GitHubRepositoryError(RuntimeError):
|
|
26
|
+
"""Raised when a GitHub repository operation fails."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@shared_task
|
|
31
|
+
def report_exception_to_github(payload: dict[str, Any]) -> None:
|
|
32
|
+
"""Send exception context to the GitHub issue helper.
|
|
33
|
+
|
|
34
|
+
The task is intentionally light-weight in this repository. Deployments can
|
|
35
|
+
replace it with an implementation that forwards ``payload`` to the
|
|
36
|
+
automation responsible for creating GitHub issues.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
logger.info(
|
|
40
|
+
"Queued GitHub issue report for %s", payload.get("fingerprint", "<unknown>")
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _resolve_github_token(package: Package | None) -> str:
|
|
45
|
+
"""Return the GitHub token for ``package``.
|
|
46
|
+
|
|
47
|
+
Preference is given to the release manager associated with the package.
|
|
48
|
+
When unavailable, fall back to the ``GITHUB_TOKEN`` environment variable.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
if package:
|
|
52
|
+
manager = getattr(package, "release_manager", None)
|
|
53
|
+
if manager:
|
|
54
|
+
token = getattr(manager, "github_token", "")
|
|
55
|
+
if token:
|
|
56
|
+
cleaned = str(token).strip()
|
|
57
|
+
if cleaned:
|
|
58
|
+
return cleaned
|
|
59
|
+
|
|
60
|
+
token = os.environ.get("GITHUB_TOKEN", "")
|
|
61
|
+
cleaned_env = token.strip() if isinstance(token, str) else str(token).strip()
|
|
62
|
+
if not cleaned_env:
|
|
63
|
+
raise GitHubRepositoryError("GitHub token is not configured")
|
|
64
|
+
return cleaned_env
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _build_headers(token: str) -> Mapping[str, str]:
|
|
68
|
+
return {
|
|
69
|
+
"Accept": "application/vnd.github+json",
|
|
70
|
+
"Authorization": f"token {token}",
|
|
71
|
+
"User-Agent": "arthexis-admin",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _build_payload(repo: str, *, private: bool, description: str | None) -> dict[str, Any]:
|
|
76
|
+
payload: dict[str, Any] = {"name": repo, "private": private}
|
|
77
|
+
if description:
|
|
78
|
+
payload["description"] = description
|
|
79
|
+
return payload
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _extract_error_message(response: requests.Response) -> str:
|
|
83
|
+
try:
|
|
84
|
+
data = response.json()
|
|
85
|
+
except ValueError:
|
|
86
|
+
data = {}
|
|
87
|
+
|
|
88
|
+
message = data.get("message") or response.text or "GitHub repository request failed"
|
|
89
|
+
errors = data.get("errors")
|
|
90
|
+
details: list[str] = []
|
|
91
|
+
if isinstance(errors, list):
|
|
92
|
+
for entry in errors:
|
|
93
|
+
if isinstance(entry, str):
|
|
94
|
+
details.append(entry)
|
|
95
|
+
elif isinstance(entry, Mapping):
|
|
96
|
+
text = entry.get("message") or entry.get("code")
|
|
97
|
+
if text:
|
|
98
|
+
details.append(str(text))
|
|
99
|
+
|
|
100
|
+
if details:
|
|
101
|
+
message = f"{message} ({'; '.join(details)})"
|
|
102
|
+
|
|
103
|
+
return message
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _safe_json(response: requests.Response) -> dict[str, Any]:
|
|
107
|
+
try:
|
|
108
|
+
data = response.json()
|
|
109
|
+
except ValueError:
|
|
110
|
+
data = {}
|
|
111
|
+
return data
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def create_repository_for_package(
|
|
115
|
+
package: Package,
|
|
116
|
+
*,
|
|
117
|
+
owner: str,
|
|
118
|
+
repo: str,
|
|
119
|
+
private: bool = False,
|
|
120
|
+
description: str | None = None,
|
|
121
|
+
) -> str:
|
|
122
|
+
"""Create a GitHub repository and return its canonical URL.
|
|
123
|
+
|
|
124
|
+
The helper attempts to create the repository under ``owner`` when provided.
|
|
125
|
+
If the authenticated token lacks access to the organization, the helper
|
|
126
|
+
falls back to creating the repository for the authenticated user. On
|
|
127
|
+
success, the GitHub HTML URL for the repository is returned.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
token = _resolve_github_token(package)
|
|
131
|
+
headers = _build_headers(token)
|
|
132
|
+
payload = _build_payload(repo, private=private, description=description)
|
|
133
|
+
|
|
134
|
+
endpoints: list[str] = []
|
|
135
|
+
owner = owner.strip()
|
|
136
|
+
if owner:
|
|
137
|
+
endpoints.append(f"{GITHUB_API_ROOT}/orgs/{owner}/repos")
|
|
138
|
+
endpoints.append(f"{GITHUB_API_ROOT}/user/repos")
|
|
139
|
+
|
|
140
|
+
last_error: str | None = None
|
|
141
|
+
|
|
142
|
+
for index, endpoint in enumerate(endpoints):
|
|
143
|
+
try:
|
|
144
|
+
response = requests.post(
|
|
145
|
+
endpoint,
|
|
146
|
+
json=payload,
|
|
147
|
+
headers=headers,
|
|
148
|
+
timeout=REQUEST_TIMEOUT,
|
|
149
|
+
)
|
|
150
|
+
except requests.RequestException as exc: # pragma: no cover - network failure
|
|
151
|
+
logger.exception(
|
|
152
|
+
"GitHub repository creation request failed for %s/%s", owner, repo
|
|
153
|
+
)
|
|
154
|
+
raise GitHubRepositoryError(str(exc)) from exc
|
|
155
|
+
|
|
156
|
+
if 200 <= response.status_code < 300:
|
|
157
|
+
data = _safe_json(response)
|
|
158
|
+
html_url = data.get("html_url")
|
|
159
|
+
if html_url:
|
|
160
|
+
return html_url
|
|
161
|
+
|
|
162
|
+
resolved_owner = (
|
|
163
|
+
data.get("owner", {}).get("login")
|
|
164
|
+
if isinstance(data.get("owner"), Mapping)
|
|
165
|
+
else owner
|
|
166
|
+
)
|
|
167
|
+
resolved_owner = (resolved_owner or owner).strip("/")
|
|
168
|
+
return f"https://github.com/{resolved_owner}/{repo}"
|
|
169
|
+
|
|
170
|
+
message = _extract_error_message(response)
|
|
171
|
+
logger.error(
|
|
172
|
+
"GitHub repository creation failed for %s/%s (%s): %s",
|
|
173
|
+
owner or "<user>",
|
|
174
|
+
repo,
|
|
175
|
+
response.status_code,
|
|
176
|
+
message,
|
|
177
|
+
)
|
|
178
|
+
last_error = message
|
|
179
|
+
|
|
180
|
+
# If we're attempting to create within an organization and receive a
|
|
181
|
+
# not found or forbidden error, fall back to creating for the
|
|
182
|
+
# authenticated user.
|
|
183
|
+
if index == 0 and owner and response.status_code in {403, 404}:
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
break
|
|
187
|
+
|
|
188
|
+
raise GitHubRepositoryError(last_error or "GitHub repository creation failed")
|
core/github_issues.py
CHANGED
|
@@ -1,172 +1,178 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import hashlib
|
|
4
|
-
import logging
|
|
5
|
-
import os
|
|
6
|
-
from datetime import datetime, timedelta
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
from typing import Iterable, Mapping
|
|
9
|
-
|
|
10
|
-
import requests
|
|
11
|
-
|
|
12
|
-
from .models import Package, PackageRelease
|
|
13
|
-
from .release import DEFAULT_PACKAGE
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
logger = logging.getLogger(__name__)
|
|
17
|
-
|
|
18
|
-
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
19
|
-
LOCK_DIR = BASE_DIR / "locks" / "github-issues"
|
|
20
|
-
LOCK_TTL = timedelta(hours=1)
|
|
21
|
-
REQUEST_TIMEOUT = 10
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def resolve_repository() -> tuple[str, str]:
|
|
25
|
-
"""Return the ``(owner, repo)`` tuple for the active package."""
|
|
26
|
-
|
|
27
|
-
package = Package.objects.filter(is_active=True).first()
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
response.
|
|
171
|
-
|
|
172
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Iterable, Mapping
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from .models import Package, PackageRelease
|
|
13
|
+
from .release import DEFAULT_PACKAGE
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
19
|
+
LOCK_DIR = BASE_DIR / "locks" / "github-issues"
|
|
20
|
+
LOCK_TTL = timedelta(hours=1)
|
|
21
|
+
REQUEST_TIMEOUT = 10
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_repository() -> tuple[str, str]:
|
|
25
|
+
"""Return the ``(owner, repo)`` tuple for the active package."""
|
|
26
|
+
|
|
27
|
+
package = Package.objects.filter(is_active=True).first()
|
|
28
|
+
|
|
29
|
+
repository_url: str
|
|
30
|
+
if package is not None:
|
|
31
|
+
raw_url = getattr(package, "repository_url", "")
|
|
32
|
+
if raw_url is None:
|
|
33
|
+
cleaned_url = ""
|
|
34
|
+
else:
|
|
35
|
+
cleaned_url = str(raw_url).strip()
|
|
36
|
+
repository_url = cleaned_url or DEFAULT_PACKAGE.repository_url
|
|
37
|
+
else:
|
|
38
|
+
repository_url = DEFAULT_PACKAGE.repository_url
|
|
39
|
+
|
|
40
|
+
owner: str
|
|
41
|
+
repo: str
|
|
42
|
+
|
|
43
|
+
if repository_url.startswith("git@"):
|
|
44
|
+
_, _, remainder = repository_url.partition(":")
|
|
45
|
+
path = remainder
|
|
46
|
+
else:
|
|
47
|
+
from urllib.parse import urlparse
|
|
48
|
+
|
|
49
|
+
parsed = urlparse(repository_url)
|
|
50
|
+
path = parsed.path
|
|
51
|
+
|
|
52
|
+
path = path.strip("/")
|
|
53
|
+
if path.endswith(".git"):
|
|
54
|
+
path = path[:-4]
|
|
55
|
+
|
|
56
|
+
segments = [segment for segment in path.split("/") if segment]
|
|
57
|
+
if len(segments) < 2:
|
|
58
|
+
raise ValueError(f"Invalid repository URL: {repository_url!r}")
|
|
59
|
+
|
|
60
|
+
owner, repo = segments[-2], segments[-1]
|
|
61
|
+
return owner, repo
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_github_token() -> str:
|
|
65
|
+
"""Return the configured GitHub token.
|
|
66
|
+
|
|
67
|
+
Preference is given to the latest :class:`~core.models.PackageRelease`.
|
|
68
|
+
When unavailable, fall back to the ``GITHUB_TOKEN`` environment variable.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
latest_release = PackageRelease.latest()
|
|
72
|
+
if latest_release:
|
|
73
|
+
token = latest_release.get_github_token()
|
|
74
|
+
if token:
|
|
75
|
+
return token
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
return os.environ["GITHUB_TOKEN"]
|
|
79
|
+
except KeyError as exc: # pragma: no cover - defensive guard
|
|
80
|
+
raise RuntimeError("GitHub token is not configured") from exc
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _ensure_lock_dir() -> None:
|
|
84
|
+
LOCK_DIR.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _fingerprint_digest(fingerprint: str) -> str:
|
|
88
|
+
return hashlib.sha256(str(fingerprint).encode("utf-8")).hexdigest()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _fingerprint_path(fingerprint: str) -> Path:
|
|
92
|
+
return LOCK_DIR / _fingerprint_digest(fingerprint)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _has_recent_marker(lock_path: Path) -> bool:
|
|
96
|
+
if not lock_path.exists():
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
marker_age = datetime.utcnow() - datetime.utcfromtimestamp(
|
|
100
|
+
lock_path.stat().st_mtime
|
|
101
|
+
)
|
|
102
|
+
return marker_age < LOCK_TTL
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def build_issue_payload(
|
|
106
|
+
title: str,
|
|
107
|
+
body: str,
|
|
108
|
+
labels: Iterable[str] | None = None,
|
|
109
|
+
fingerprint: str | None = None,
|
|
110
|
+
) -> Mapping[str, object] | None:
|
|
111
|
+
"""Return an API payload for GitHub issues.
|
|
112
|
+
|
|
113
|
+
When ``fingerprint`` is provided, duplicate submissions within ``LOCK_TTL``
|
|
114
|
+
are ignored by returning ``None``. A marker is kept on disk to prevent
|
|
115
|
+
repeated reports during the cooldown window.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
payload: dict[str, object] = {"title": title, "body": body}
|
|
119
|
+
|
|
120
|
+
if labels:
|
|
121
|
+
deduped = list(dict.fromkeys(labels))
|
|
122
|
+
if deduped:
|
|
123
|
+
payload["labels"] = deduped
|
|
124
|
+
|
|
125
|
+
if fingerprint:
|
|
126
|
+
_ensure_lock_dir()
|
|
127
|
+
lock_path = _fingerprint_path(fingerprint)
|
|
128
|
+
if _has_recent_marker(lock_path):
|
|
129
|
+
logger.info("Skipping GitHub issue for active fingerprint %s", fingerprint)
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
lock_path.write_text(datetime.utcnow().isoformat(), encoding="utf-8")
|
|
133
|
+
digest = _fingerprint_digest(fingerprint)
|
|
134
|
+
payload["body"] = f"{body}\n\n<!-- fingerprint:{digest} -->"
|
|
135
|
+
|
|
136
|
+
return payload
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def create_issue(
|
|
140
|
+
title: str,
|
|
141
|
+
body: str,
|
|
142
|
+
labels: Iterable[str] | None = None,
|
|
143
|
+
fingerprint: str | None = None,
|
|
144
|
+
) -> requests.Response | None:
|
|
145
|
+
"""Create a GitHub issue using the configured repository and token."""
|
|
146
|
+
|
|
147
|
+
payload = build_issue_payload(title, body, labels=labels, fingerprint=fingerprint)
|
|
148
|
+
if payload is None:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
owner, repo = resolve_repository()
|
|
152
|
+
token = get_github_token()
|
|
153
|
+
|
|
154
|
+
headers = {
|
|
155
|
+
"Accept": "application/vnd.github+json",
|
|
156
|
+
"Authorization": f"token {token}",
|
|
157
|
+
"User-Agent": "arthexis-runtime-reporter",
|
|
158
|
+
}
|
|
159
|
+
url = f"https://api.github.com/repos/{owner}/{repo}/issues"
|
|
160
|
+
|
|
161
|
+
response = requests.post(
|
|
162
|
+
url, json=payload, headers=headers, timeout=REQUEST_TIMEOUT
|
|
163
|
+
)
|
|
164
|
+
if not (200 <= response.status_code < 300):
|
|
165
|
+
logger.error(
|
|
166
|
+
"GitHub issue creation failed with status %s: %s",
|
|
167
|
+
response.status_code,
|
|
168
|
+
response.text,
|
|
169
|
+
)
|
|
170
|
+
response.raise_for_status()
|
|
171
|
+
|
|
172
|
+
logger.info(
|
|
173
|
+
"GitHub issue created for %s/%s with status %s",
|
|
174
|
+
owner,
|
|
175
|
+
repo,
|
|
176
|
+
response.status_code,
|
|
177
|
+
)
|
|
178
|
+
return response
|
core/github_repos.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Mapping
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from .github_issues import REQUEST_TIMEOUT, get_github_token
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _build_repository_payload(
|
|
15
|
+
repo: str,
|
|
16
|
+
visibility: str,
|
|
17
|
+
description: str | None,
|
|
18
|
+
) -> Mapping[str, object]:
|
|
19
|
+
payload: dict[str, object] = {"name": repo, "visibility": visibility}
|
|
20
|
+
|
|
21
|
+
if description is not None:
|
|
22
|
+
payload["description"] = description
|
|
23
|
+
|
|
24
|
+
return payload
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def create_repository(
|
|
28
|
+
owner: str | None,
|
|
29
|
+
repo: str,
|
|
30
|
+
*,
|
|
31
|
+
visibility: str = "private",
|
|
32
|
+
description: str | None = None,
|
|
33
|
+
) -> requests.Response:
|
|
34
|
+
"""Create a GitHub repository for the authenticated user or organisation."""
|
|
35
|
+
|
|
36
|
+
token = get_github_token()
|
|
37
|
+
|
|
38
|
+
headers = {
|
|
39
|
+
"Accept": "application/vnd.github+json",
|
|
40
|
+
"Authorization": f"token {token}",
|
|
41
|
+
"User-Agent": "arthexis-runtime-reporter",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if owner:
|
|
45
|
+
url = f"https://api.github.com/orgs/{owner}/repos"
|
|
46
|
+
else:
|
|
47
|
+
url = "https://api.github.com/user/repos"
|
|
48
|
+
|
|
49
|
+
payload = _build_repository_payload(repo, visibility, description)
|
|
50
|
+
|
|
51
|
+
response = requests.post(
|
|
52
|
+
url,
|
|
53
|
+
json=payload,
|
|
54
|
+
headers=headers,
|
|
55
|
+
timeout=REQUEST_TIMEOUT,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if not (200 <= response.status_code < 300):
|
|
59
|
+
logger.error(
|
|
60
|
+
"GitHub repository creation failed with status %s: %s",
|
|
61
|
+
response.status_code,
|
|
62
|
+
response.text,
|
|
63
|
+
)
|
|
64
|
+
response.raise_for_status()
|
|
65
|
+
|
|
66
|
+
logger.info(
|
|
67
|
+
"GitHub repository created for %s with status %s",
|
|
68
|
+
owner or "authenticated user",
|
|
69
|
+
response.status_code,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return response
|