outerloop-science 0.1.0.dev0__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.
- outerloop/__init__.py +18 -0
- outerloop/__main__.py +3 -0
- outerloop/appauth.py +213 -0
- outerloop/appmanifest.py +198 -0
- outerloop/attempt.py +3481 -0
- outerloop/brief.py +515 -0
- outerloop/cli.py +439 -0
- outerloop/climbboard.py +1145 -0
- outerloop/compute.py +482 -0
- outerloop/contract.py +483 -0
- outerloop/contract_cli.py +63 -0
- outerloop/disk.py +164 -0
- outerloop/dispatch.py +586 -0
- outerloop/followup.py +2143 -0
- outerloop/github.py +1486 -0
- outerloop/harness.py +1449 -0
- outerloop/housekeeping.py +167 -0
- outerloop/init.py +313 -0
- outerloop/intake.py +129 -0
- outerloop/limits.py +80 -0
- outerloop/markers.py +48 -0
- outerloop/measure.py +523 -0
- outerloop/orchestrator.py +1901 -0
- outerloop/panel.py +188 -0
- outerloop/paths.py +27 -0
- outerloop/posting.py +160 -0
- outerloop/progress.py +170 -0
- outerloop/py.typed +0 -0
- outerloop/review.py +611 -0
- outerloop/review_agent.py +263 -0
- outerloop/review_agent_cli.py +209 -0
- outerloop/review_post_cli.py +162 -0
- outerloop/review_summarize_cli.py +163 -0
- outerloop/role_runner.py +229 -0
- outerloop/roles.py +247 -0
- outerloop/rolespec.py +89 -0
- outerloop/runstate.py +385 -0
- outerloop/steward.py +852 -0
- outerloop/style.py +12 -0
- outerloop/syscall.py +977 -0
- outerloop/syscall_cli.py +531 -0
- outerloop/tick.py +3166 -0
- outerloop/verifier.py +403 -0
- outerloop/verify_agent.py +149 -0
- outerloop/verify_agent_cli.py +95 -0
- outerloop/verify_post_cli.py +116 -0
- outerloop_science-0.1.0.dev0.dist-info/METADATA +145 -0
- outerloop_science-0.1.0.dev0.dist-info/RECORD +52 -0
- outerloop_science-0.1.0.dev0.dist-info/WHEEL +4 -0
- outerloop_science-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- outerloop_science-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
- outerloop_science-0.1.0.dev0.dist-info/licenses/NOTICE +5 -0
outerloop/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Autonomous research agent that co-develops the lab's benchmark-bearing repos."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0.dev0"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _bridge_legacy_env() -> None:
|
|
9
|
+
"""Accept the new `OUTERLOOP_*` names at the process boundary: copy each into
|
|
10
|
+
its `AUTORESEARCH_*` twin when the twin is unset, so the kernel's internals —
|
|
11
|
+
which still read `AUTORESEARCH_*` during the rename — see one name. The chain
|
|
12
|
+
scripts do the same in shell. Removed with the final internal flip."""
|
|
13
|
+
for key, value in list(os.environ.items()):
|
|
14
|
+
if key.startswith("OUTERLOOP_"):
|
|
15
|
+
os.environ.setdefault("AUTORESEARCH_" + key[len("OUTERLOOP_") :], value)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_bridge_legacy_env()
|
outerloop/__main__.py
ADDED
outerloop/appauth.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""GitHub App installation-token auth (docs/design/github-app-auth.md).
|
|
2
|
+
|
|
3
|
+
`AppInstallationTokenProvider` satisfies the `TokenProvider` protocol used
|
|
4
|
+
throughout `github.py`. Role CLIs construct bot auth through
|
|
5
|
+
`resolve_bot_auth`, which selects this provider when an App config file is
|
|
6
|
+
given (`--github-app-file` / `AUTORESEARCH_GITHUB_APP_FILE`) and falls back
|
|
7
|
+
to the PAT file otherwise — the cutover flag, revertible by unsetting the
|
|
8
|
+
env. Each `token()` mints a short-lived JWT (RS256, signed by the App
|
|
9
|
+
private key), exchanges it for a ~1h installation token scoped to the
|
|
10
|
+
installation's repos, and caches that token until a refresh margin before
|
|
11
|
+
it expires.
|
|
12
|
+
|
|
13
|
+
The RS256 signer and the HTTP transport are injected so the provider — and
|
|
14
|
+
its tests — build and run without the `cryptography` dependency or a
|
|
15
|
+
network; the production signer lives behind the `app-auth` extra.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import base64
|
|
21
|
+
import json
|
|
22
|
+
import time
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.parse
|
|
25
|
+
import urllib.request
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from outerloop.github import AUTH_SAFE_OPENER, FileTokenProvider, TokenProvider
|
|
32
|
+
|
|
33
|
+
# RS256-sign the JWT signing input, returning the raw signature bytes.
|
|
34
|
+
Signer = Callable[[bytes], bytes]
|
|
35
|
+
# Perform the token-exchange POST and return the parsed JSON body.
|
|
36
|
+
Transport = Callable[[urllib.request.Request], Any]
|
|
37
|
+
|
|
38
|
+
API = "https://api.github.com"
|
|
39
|
+
# GitHub caps App JWT lifetime at 10 minutes; stay comfortably under it.
|
|
40
|
+
_JWT_TTL_S = 9 * 60
|
|
41
|
+
# Backdate `iat` to tolerate clock skew between us and GitHub.
|
|
42
|
+
_JWT_BACKDATE_S = 60
|
|
43
|
+
# Re-mint the installation token this long before it actually expires, so a
|
|
44
|
+
# token is never handed out on the edge of expiry.
|
|
45
|
+
_REFRESH_MARGIN_S = 5 * 60
|
|
46
|
+
|
|
47
|
+
# Every installation token minted by this process, whichever provider
|
|
48
|
+
# instance minted it. `redact` appends these to its snapshotted secrets
|
|
49
|
+
# tuple at write time, so a token minted after a call site captured its
|
|
50
|
+
# tuple still never reaches a report, record, or log.
|
|
51
|
+
_ISSUED_TOKENS: list[str] = []
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def issued_tokens() -> tuple[str, ...]:
|
|
55
|
+
"""All installation tokens minted this process, for write-time redaction."""
|
|
56
|
+
return tuple(_ISSUED_TOKENS)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _b64url(data: bytes) -> str:
|
|
60
|
+
"""URL-safe base64 without padding — the JWT wire encoding."""
|
|
61
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_app_jwt(app_id: int, now: float, sign: Signer) -> str:
|
|
65
|
+
"""A GitHub App JWT: header.payload.signature, RS256 over the first two."""
|
|
66
|
+
header = _b64url(json.dumps({"alg": "RS256", "typ": "JWT"}).encode())
|
|
67
|
+
payload = _b64url(
|
|
68
|
+
json.dumps(
|
|
69
|
+
{
|
|
70
|
+
"iat": int(now) - _JWT_BACKDATE_S,
|
|
71
|
+
"exp": int(now) + _JWT_TTL_S,
|
|
72
|
+
"iss": str(app_id),
|
|
73
|
+
}
|
|
74
|
+
).encode()
|
|
75
|
+
)
|
|
76
|
+
signing_input = f"{header}.{payload}"
|
|
77
|
+
signature = _b64url(sign(signing_input.encode("ascii")))
|
|
78
|
+
return f"{signing_input}.{signature}"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _parse_expiry(expires_at: str) -> float:
|
|
82
|
+
"""`2026-09-01T12:00:00Z` (or with an offset) -> unix seconds."""
|
|
83
|
+
text = expires_at.strip()
|
|
84
|
+
if text.endswith("Z"):
|
|
85
|
+
text = text[:-1] + "+00:00"
|
|
86
|
+
return datetime.fromisoformat(text).timestamp()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _default_transport(request: urllib.request.Request) -> Any:
|
|
90
|
+
# the shared opener: a cross-host redirect must not forward the App JWT
|
|
91
|
+
try:
|
|
92
|
+
with AUTH_SAFE_OPENER.open(request, timeout=30) as response:
|
|
93
|
+
payload = response.read()
|
|
94
|
+
except urllib.error.HTTPError as exc:
|
|
95
|
+
body = exc.read().decode(errors="replace")[:500]
|
|
96
|
+
raise ValueError(f"installation-token exchange failed ({exc.code}): {body}") from None
|
|
97
|
+
except urllib.error.URLError as exc:
|
|
98
|
+
raise ValueError(f"installation-token exchange failed: {exc.reason}") from None
|
|
99
|
+
return json.loads(payload) if payload else None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class AppInstallationTokenProvider:
|
|
103
|
+
"""A `TokenProvider` minting cached, short-lived installation tokens."""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
app_id: int,
|
|
108
|
+
installation_id: int,
|
|
109
|
+
sign: Signer,
|
|
110
|
+
*,
|
|
111
|
+
transport: Transport | None = None,
|
|
112
|
+
now: Callable[[], float] = time.time,
|
|
113
|
+
) -> None:
|
|
114
|
+
self.app_id = app_id
|
|
115
|
+
self.installation_id = installation_id
|
|
116
|
+
self._sign = sign
|
|
117
|
+
self._transport = transport or _default_transport
|
|
118
|
+
self._now = now
|
|
119
|
+
self._token = ""
|
|
120
|
+
self._expiry = 0.0
|
|
121
|
+
# every token ever minted this process — the PAT was one immortal
|
|
122
|
+
# string, but these rotate ~hourly, so a redaction set snapshotted at
|
|
123
|
+
# construction goes stale; redaction must pull issued() at write time
|
|
124
|
+
self._issued: list[str] = []
|
|
125
|
+
|
|
126
|
+
def token(self) -> str:
|
|
127
|
+
now = self._now()
|
|
128
|
+
if self._token and now < self._expiry - _REFRESH_MARGIN_S:
|
|
129
|
+
return self._token
|
|
130
|
+
jwt = build_app_jwt(self.app_id, now, self._sign)
|
|
131
|
+
request = urllib.request.Request(
|
|
132
|
+
f"{API}/app/installations/{self.installation_id}/access_tokens",
|
|
133
|
+
method="POST",
|
|
134
|
+
headers={
|
|
135
|
+
"Authorization": f"Bearer {jwt}",
|
|
136
|
+
"Accept": "application/vnd.github+json",
|
|
137
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
body = self._transport(request)
|
|
141
|
+
if not isinstance(body, dict) or not body.get("token"):
|
|
142
|
+
raise ValueError("installation-token response missing a token")
|
|
143
|
+
self._token = str(body["token"])
|
|
144
|
+
self._issued.append(self._token)
|
|
145
|
+
_ISSUED_TOKENS.append(self._token)
|
|
146
|
+
expires_at = body.get("expires_at")
|
|
147
|
+
# a missing/garbled expiry is treated as immediate — safe: we simply
|
|
148
|
+
# re-mint on every call rather than trust an unknown lifetime
|
|
149
|
+
try:
|
|
150
|
+
self._expiry = _parse_expiry(str(expires_at)) if expires_at else now
|
|
151
|
+
except ValueError:
|
|
152
|
+
self._expiry = now
|
|
153
|
+
return self._token
|
|
154
|
+
|
|
155
|
+
def issued(self) -> tuple[str, ...]:
|
|
156
|
+
"""Every token this provider has minted, for redaction sets built at
|
|
157
|
+
write time — a set snapshotted at construction misses refreshes."""
|
|
158
|
+
return tuple(self._issued)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def signer_from_private_key(pem_path: Path) -> Signer:
|
|
162
|
+
"""The production RS256 signer, built from the App private-key PEM. Lazily
|
|
163
|
+
imports `cryptography` (the `app-auth` extra) so the module and its tests
|
|
164
|
+
do not depend on it; the key file must be owner-only (chmod 600), the same
|
|
165
|
+
custody the PAT file has."""
|
|
166
|
+
if not pem_path.is_file():
|
|
167
|
+
raise ValueError(f"{pem_path} is not a readable private-key file")
|
|
168
|
+
if pem_path.stat().st_mode & 0o077:
|
|
169
|
+
raise PermissionError(f"{pem_path} is group/world accessible; chmod 600 it")
|
|
170
|
+
try:
|
|
171
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
172
|
+
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
173
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
174
|
+
raise ImportError(
|
|
175
|
+
"GitHub App auth needs the 'app-auth' extra (cryptography); "
|
|
176
|
+
"install it before selecting the App token provider"
|
|
177
|
+
) from exc
|
|
178
|
+
|
|
179
|
+
key = serialization.load_pem_private_key(pem_path.read_bytes(), password=None)
|
|
180
|
+
if not isinstance(key, rsa.RSAPrivateKey):
|
|
181
|
+
raise ValueError(f"{pem_path} is not an RSA key; GitHub App keys are RSA")
|
|
182
|
+
|
|
183
|
+
def sign(message: bytes) -> bytes:
|
|
184
|
+
return key.sign(message, padding.PKCS1v15(), hashes.SHA256())
|
|
185
|
+
|
|
186
|
+
return sign
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def app_provider_from_file(app_file: Path) -> AppInstallationTokenProvider:
|
|
190
|
+
"""Build the provider from the App config file: JSON with `app_id`,
|
|
191
|
+
`installation_id` (integers), and `private_key` (path to the PEM). The
|
|
192
|
+
ids are not secrets; the file exists so the whole App identity travels
|
|
193
|
+
as one path on the same rails the PAT path already rides."""
|
|
194
|
+
try:
|
|
195
|
+
config = json.loads(app_file.read_text())
|
|
196
|
+
except (OSError, ValueError) as exc:
|
|
197
|
+
raise ValueError(f"cannot read App config {app_file}: {exc}") from None
|
|
198
|
+
missing = [k for k in ("app_id", "installation_id", "private_key") if not config.get(k)]
|
|
199
|
+
if missing:
|
|
200
|
+
raise ValueError(f"App config {app_file} is missing {', '.join(missing)}")
|
|
201
|
+
return AppInstallationTokenProvider(
|
|
202
|
+
int(config["app_id"]),
|
|
203
|
+
int(config["installation_id"]),
|
|
204
|
+
signer_from_private_key(Path(str(config["private_key"])).expanduser()),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def resolve_bot_auth(pat_file: str | Path, app_file: str | Path = "") -> TokenProvider:
|
|
209
|
+
"""The one seam every role CLI constructs bot auth through: the App
|
|
210
|
+
provider when an App config file is given, the PAT file otherwise."""
|
|
211
|
+
if str(app_file).strip():
|
|
212
|
+
return app_provider_from_file(Path(str(app_file)).expanduser())
|
|
213
|
+
return FileTokenProvider(Path(str(pat_file)).expanduser())
|
outerloop/appmanifest.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""GitHub App Manifest flow for `outerloop init --github-app`.
|
|
2
|
+
|
|
3
|
+
Creates the adopter's OWN GitHub App in one click instead of a hand-rolled PAT.
|
|
4
|
+
Because the bot runs on the adopter's own compute (self-hosted), the App's
|
|
5
|
+
private key must live there — a shared App would mean either we run the fleet or
|
|
6
|
+
we hand out a master key — so each adopter owns their App. GitHub's manifest flow
|
|
7
|
+
makes that a click: a pre-filled create page, then a code we exchange for the key.
|
|
8
|
+
|
|
9
|
+
The flow is paste-based and hostless on the adopter's side: init prints ONE URL
|
|
10
|
+
to the hosted helper page (`setup.outerloop.science`), which carries the
|
|
11
|
+
manifest in its URL *fragment* (never sent to any server). The adopter opens it
|
|
12
|
+
in any browser — laptop or, for a headless cluster, anywhere — clicks Create,
|
|
13
|
+
and GitHub redirects back to that page with a one-time code the page displays.
|
|
14
|
+
The adopter pastes the code here; we exchange it for the app id + key, write
|
|
15
|
+
`github_app.<slug>.json` + the PEM (both 0600), help install the App, and capture
|
|
16
|
+
the installation id. The written files are what `resolve_bot_auth` reads via
|
|
17
|
+
`AUTORESEARCH_GITHUB_APP_FILE`, the same path `outerloop start` uses.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import secrets
|
|
26
|
+
import time
|
|
27
|
+
import urllib.error
|
|
28
|
+
import urllib.request
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from outerloop.appauth import API, build_app_jwt, signer_from_private_key
|
|
34
|
+
|
|
35
|
+
# The hosted helper page: it auto-POSTs the manifest to GitHub, then displays the
|
|
36
|
+
# returned code. It is also the manifest's redirect_url, so GitHub sends the code
|
|
37
|
+
# straight back to it. Its own subdomain (the apex is the landing page), served
|
|
38
|
+
# from web/ in this repo so the page and the CLI stay versioned together.
|
|
39
|
+
# `OUTERLOOP_SETUP_URL` overrides it — a staging copy, or a self-hoster's own.
|
|
40
|
+
SETUP_URL = os.environ.get("OUTERLOOP_SETUP_URL") or "https://setup.outerloop.science"
|
|
41
|
+
|
|
42
|
+
# The App's fine-grained permissions — exactly what the fleet exercises: contents
|
|
43
|
+
# to push commits, pull_requests to open/label PRs, issues for the courtesy note
|
|
44
|
+
# on the requesting issue. Nothing else (least privilege; matches the live bot).
|
|
45
|
+
DEFAULT_PERMISSIONS = {
|
|
46
|
+
"contents": "write",
|
|
47
|
+
"issues": "write",
|
|
48
|
+
"metadata": "read",
|
|
49
|
+
"pull_requests": "write",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# The POST/GET transport returns parsed JSON; injected so the pure logic tests
|
|
53
|
+
# without a network.
|
|
54
|
+
Transport = Callable[[urllib.request.Request], Any]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def build_manifest(name: str, url: str, redirect_url: str = SETUP_URL) -> dict:
|
|
58
|
+
"""The manifest GitHub creates the App from: our permissions, no webhook,
|
|
59
|
+
installable only on the creating account (`public=false`). `redirect_url` is
|
|
60
|
+
where GitHub returns the code — the hosted helper page by default."""
|
|
61
|
+
return {
|
|
62
|
+
"name": name,
|
|
63
|
+
"url": url,
|
|
64
|
+
"redirect_url": redirect_url,
|
|
65
|
+
"public": False,
|
|
66
|
+
"default_permissions": dict(DEFAULT_PERMISSIONS),
|
|
67
|
+
"hook_attributes": {"active": False, "url": url},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def build_setup_url(manifest: dict, state: str, org: str = "") -> str:
|
|
72
|
+
"""The one URL the adopter opens: the hosted helper page with the manifest,
|
|
73
|
+
state, and org packed into the URL *fragment* (base64) — a fragment stays in
|
|
74
|
+
the browser and is never sent to any server, so the manifest isn't logged."""
|
|
75
|
+
payload = json.dumps({"manifest": manifest, "state": state, "org": org})
|
|
76
|
+
encoded = base64.urlsafe_b64encode(payload.encode()).decode()
|
|
77
|
+
return f"{SETUP_URL}#{encoded}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def request_manifest_code(
|
|
81
|
+
name: str,
|
|
82
|
+
homepage_url: str,
|
|
83
|
+
org: str = "",
|
|
84
|
+
*,
|
|
85
|
+
print_fn: Callable[[str], None] = print,
|
|
86
|
+
input_fn: Callable[[str], str] = input,
|
|
87
|
+
) -> str:
|
|
88
|
+
"""Print the setup URL and read back the code the hosted page shows. Works
|
|
89
|
+
anywhere a browser can reach the internet — no localhost, no callback."""
|
|
90
|
+
state = secrets.token_urlsafe(16)
|
|
91
|
+
manifest = build_manifest(name, homepage_url)
|
|
92
|
+
url = build_setup_url(manifest, state, org)
|
|
93
|
+
print_fn("Create your GitHub App — open this URL in a browser (any machine):")
|
|
94
|
+
print_fn(f"\n {url}\n")
|
|
95
|
+
print_fn("Click 'Create GitHub App', then copy the code the page shows.")
|
|
96
|
+
return input_fn("Paste the code here: ").strip()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _default_transport(request: urllib.request.Request) -> Any:
|
|
100
|
+
with urllib.request.urlopen(request, timeout=30) as resp:
|
|
101
|
+
payload = resp.read()
|
|
102
|
+
return json.loads(payload) if payload else None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def convert_manifest(code: str, *, transport: Transport | None = None) -> dict:
|
|
106
|
+
"""Exchange the one-time manifest `code` for the App's credentials (id, pem,
|
|
107
|
+
slug, ...). GitHub invalidates the code on first use and after ~1h."""
|
|
108
|
+
transport = transport or _default_transport
|
|
109
|
+
request = urllib.request.Request(
|
|
110
|
+
f"{API}/app-manifests/{code}/conversions",
|
|
111
|
+
method="POST",
|
|
112
|
+
headers={
|
|
113
|
+
"Accept": "application/vnd.github+json",
|
|
114
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
try:
|
|
118
|
+
body = transport(request)
|
|
119
|
+
except urllib.error.HTTPError as exc:
|
|
120
|
+
detail = exc.read().decode(errors="replace")[:200]
|
|
121
|
+
raise ValueError(f"manifest conversion failed ({exc.code}): {detail}") from None
|
|
122
|
+
if not isinstance(body, dict):
|
|
123
|
+
raise ValueError("manifest conversion returned no object")
|
|
124
|
+
missing = [k for k in ("id", "pem", "slug") if not body.get(k)]
|
|
125
|
+
if missing:
|
|
126
|
+
raise ValueError(f"manifest conversion missing {', '.join(missing)}")
|
|
127
|
+
return body
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def save_app_creds(
|
|
131
|
+
conversion: dict, config_dir: Path, installation_id: int = 0
|
|
132
|
+
) -> tuple[Path, Path]:
|
|
133
|
+
"""Write the PEM (0600) and `github_app.<slug>.json` pointing at it, both
|
|
134
|
+
owner-only. `installation_id` stays 0 until the App is installed and
|
|
135
|
+
`capture_installation_id` fills it. Returns (pem_path, app_json_path)."""
|
|
136
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
slug = str(conversion["slug"])
|
|
138
|
+
pem_path = config_dir / f"{slug}-app.pem"
|
|
139
|
+
pem_path.write_text(str(conversion["pem"]))
|
|
140
|
+
pem_path.chmod(0o600)
|
|
141
|
+
app_json = config_dir / f"github_app.{slug}.json"
|
|
142
|
+
app_json.write_text(
|
|
143
|
+
json.dumps(
|
|
144
|
+
{
|
|
145
|
+
"app_id": int(conversion["id"]),
|
|
146
|
+
"installation_id": int(installation_id),
|
|
147
|
+
"private_key": str(pem_path),
|
|
148
|
+
},
|
|
149
|
+
indent=2,
|
|
150
|
+
)
|
|
151
|
+
+ "\n"
|
|
152
|
+
)
|
|
153
|
+
app_json.chmod(0o600)
|
|
154
|
+
return pem_path, app_json
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def install_url(conversion: dict) -> str:
|
|
158
|
+
"""Where the adopter installs the freshly created App on their repos."""
|
|
159
|
+
return f"https://github.com/apps/{conversion['slug']}/installations/new"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def capture_installation_id(
|
|
163
|
+
app_id: int,
|
|
164
|
+
pem_path: Path,
|
|
165
|
+
owner: str = "",
|
|
166
|
+
*,
|
|
167
|
+
transport: Transport | None = None,
|
|
168
|
+
now: Callable[[], float] = time.time,
|
|
169
|
+
) -> int:
|
|
170
|
+
"""After the App is installed, its installation id for `owner` (or the sole
|
|
171
|
+
installation when `owner` is blank). App JWT → GET /app/installations. 0 when
|
|
172
|
+
nothing is installed yet — the caller retries after the adopter installs."""
|
|
173
|
+
transport = transport or _default_transport
|
|
174
|
+
sign = signer_from_private_key(pem_path)
|
|
175
|
+
jwt = build_app_jwt(app_id, now(), sign)
|
|
176
|
+
request = urllib.request.Request(
|
|
177
|
+
f"{API}/app/installations",
|
|
178
|
+
headers={
|
|
179
|
+
"Authorization": f"Bearer {jwt}",
|
|
180
|
+
"Accept": "application/vnd.github+json",
|
|
181
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
installs = transport(request) or []
|
|
185
|
+
if owner:
|
|
186
|
+
for inst in installs:
|
|
187
|
+
if str((inst.get("account") or {}).get("login", "")).lower() == owner.lower():
|
|
188
|
+
return int(inst["id"])
|
|
189
|
+
return 0
|
|
190
|
+
return int(installs[0]["id"]) if installs else 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def set_installation_id(app_json: Path, installation_id: int) -> None:
|
|
194
|
+
"""Fill the installation id into an already-written github_app.<slug>.json."""
|
|
195
|
+
data = json.loads(app_json.read_text())
|
|
196
|
+
data["installation_id"] = int(installation_id)
|
|
197
|
+
app_json.write_text(json.dumps(data, indent=2) + "\n")
|
|
198
|
+
app_json.chmod(0o600)
|