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/github.py
ADDED
|
@@ -0,0 +1,1486 @@
|
|
|
1
|
+
"""GitHub operations for the orchestrator: REST and git, behind a token provider.
|
|
2
|
+
|
|
3
|
+
Auth flows through :class:`TokenProvider` so a GitHub App installation-token
|
|
4
|
+
provider can replace the PAT file without touching callers
|
|
5
|
+
(docs/design/external.md). Every mutating operation respects ``dry_run``: log
|
|
6
|
+
the intent, touch nothing.
|
|
7
|
+
|
|
8
|
+
Credential rules enforced here:
|
|
9
|
+
- Tokens never appear in process arguments or in ``.git/config``; git auth is
|
|
10
|
+
injected per-invocation via ``GIT_CONFIG_*`` environment variables.
|
|
11
|
+
- Only network git subcommands get that environment. Local subcommands run
|
|
12
|
+
token-free with hooks disabled, so repo content written by an agent session
|
|
13
|
+
(hooks, filters) can never read the credential.
|
|
14
|
+
- Credentialed invocations additionally neutralize every git setting that can
|
|
15
|
+
spawn a child process (ssh command, credential helpers, alternate
|
|
16
|
+
protocols), because the session owns the clone's ``.git/config``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import base64
|
|
22
|
+
import contextlib
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import stat
|
|
28
|
+
import subprocess
|
|
29
|
+
import urllib.error
|
|
30
|
+
import urllib.parse
|
|
31
|
+
import urllib.request
|
|
32
|
+
from collections.abc import Callable
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Protocol
|
|
36
|
+
|
|
37
|
+
from outerloop.contract import CONTRACT_NAMES, find_contract
|
|
38
|
+
from outerloop.markers import legacy_marker, marker
|
|
39
|
+
|
|
40
|
+
DEFAULT_BOT_LOGIN = "agentic-learning-bot"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def bot_login_from_env(default: str = DEFAULT_BOT_LOGIN) -> str:
|
|
44
|
+
"""The login the kernel posts and pushes as — the bot ACCOUNT under a PAT,
|
|
45
|
+
the App's `<slug>[bot]` under App auth (docs/design/github-app-auth.md).
|
|
46
|
+
`AUTORESEARCH_BOT_LOGIN` sets it for every role at once (the tick exports
|
|
47
|
+
it, jobs inherit it); every own-comment filter, alarm-issue lookup and
|
|
48
|
+
intake-claim scan keys on this string, so it must follow the credential."""
|
|
49
|
+
return os.environ.get("AUTORESEARCH_BOT_LOGIN", "").strip() or default
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def bot_aliases_from_env() -> tuple[str, ...]:
|
|
53
|
+
"""Former logins the kernel posted as (comma-separated
|
|
54
|
+
`AUTORESEARCH_BOT_ALIASES`). Every issue, claim, alarm and PR created
|
|
55
|
+
before an identity flip carries the OLD login; recognition stays keyed on
|
|
56
|
+
login — never on the public markers, which anyone can paste — so the set
|
|
57
|
+
of our logins is what a flip must widen (live: after the App flip the
|
|
58
|
+
intake lane claimed the kernel's own research-log issue, authored by the
|
|
59
|
+
PAT account)."""
|
|
60
|
+
raw = os.environ.get("AUTORESEARCH_BOT_ALIASES", "")
|
|
61
|
+
return tuple(dict.fromkeys(a.strip() for a in raw.split(",") if a.strip()))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_own_login(login: str, bot_login: str) -> bool:
|
|
65
|
+
"""Whether `login` is one of the kernel's identities: the login it posts
|
|
66
|
+
as now, or a former one. Blank never matches (a blank identity must fail
|
|
67
|
+
closed at every gate, as before)."""
|
|
68
|
+
who = login.strip().casefold()
|
|
69
|
+
if not who or not bot_login.strip():
|
|
70
|
+
return False # no identity of our own = no identity at all (aliases included)
|
|
71
|
+
ours = {bot_login.strip().casefold()} | {a.casefold() for a in bot_aliases_from_env()}
|
|
72
|
+
ours.discard("")
|
|
73
|
+
return who in ours
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
log = logging.getLogger(__name__)
|
|
77
|
+
|
|
78
|
+
API = "https://api.github.com"
|
|
79
|
+
NETWORK_GIT_COMMANDS = frozenset({"clone", "fetch", "pull", "push", "ls-remote"})
|
|
80
|
+
# a remote that accepts the connection but never finishes must not hang the
|
|
81
|
+
# wake forever (the run would sit blocked until Slurm's walltime kill)
|
|
82
|
+
NETWORK_GIT_TIMEOUT_S = 900
|
|
83
|
+
# Settings a session could add to .git/config that make git spawn a child
|
|
84
|
+
# process; neutralized on every credentialed invocation.
|
|
85
|
+
SAFE_GIT_FLAGS = (
|
|
86
|
+
"-c",
|
|
87
|
+
"core.hooksPath=/dev/null",
|
|
88
|
+
"-c",
|
|
89
|
+
"core.sshCommand=false",
|
|
90
|
+
"-c",
|
|
91
|
+
"credential.helper=",
|
|
92
|
+
"-c",
|
|
93
|
+
"protocol.allow=never",
|
|
94
|
+
"-c",
|
|
95
|
+
"protocol.https.allow=always",
|
|
96
|
+
"-c",
|
|
97
|
+
"protocol.file.allow=always",
|
|
98
|
+
"-c",
|
|
99
|
+
"core.fsmonitor=",
|
|
100
|
+
"-c",
|
|
101
|
+
"core.quotePath=false",
|
|
102
|
+
)
|
|
103
|
+
Transport = Callable[[urllib.request.Request], Any]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class GitHubError(RuntimeError):
|
|
107
|
+
"""A GitHub API call failed."""
|
|
108
|
+
|
|
109
|
+
def __init__(self, status: int, path: str, message: str) -> None:
|
|
110
|
+
super().__init__(f"{status} on {path}: {message}")
|
|
111
|
+
self.status = status
|
|
112
|
+
self.path = path
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class GitError(RuntimeError):
|
|
116
|
+
"""A git subcommand failed; carries git's own explanation."""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class NothingToCommit(GitError):
|
|
120
|
+
"""Nothing staged — an ordinary outcome, not a crash."""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class ForbiddenPathError(GitError):
|
|
124
|
+
"""A commit tried to stage a path the contract forbids."""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class TokenProvider(Protocol):
|
|
128
|
+
def token(self) -> str: ...
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class EnvTokenProvider:
|
|
133
|
+
"""Reads a token from an environment variable (CI-supplied credentials)."""
|
|
134
|
+
|
|
135
|
+
variable: str
|
|
136
|
+
|
|
137
|
+
def token(self) -> str:
|
|
138
|
+
token = os.environ.get(self.variable, "").strip()
|
|
139
|
+
if not token:
|
|
140
|
+
raise ValueError(f"{self.variable} is unset or empty")
|
|
141
|
+
return token
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True)
|
|
145
|
+
class FileTokenProvider:
|
|
146
|
+
"""Reads a credential file (the bot PAT on the orchestrator host)."""
|
|
147
|
+
|
|
148
|
+
path: Path
|
|
149
|
+
|
|
150
|
+
def token(self) -> str:
|
|
151
|
+
if not self.path.is_file():
|
|
152
|
+
raise ValueError(f"{self.path} is not a readable credential file")
|
|
153
|
+
mode = self.path.stat().st_mode & 0o077
|
|
154
|
+
if mode:
|
|
155
|
+
raise PermissionError(f"{self.path} is group/world accessible; chmod 600 it")
|
|
156
|
+
token = self.path.read_text().strip()
|
|
157
|
+
if not token:
|
|
158
|
+
raise ValueError(f"{self.path} is empty")
|
|
159
|
+
return token
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class _NoAuthRedirect(urllib.request.HTTPRedirectHandler):
|
|
163
|
+
"""Drop the Authorization header when a redirect changes host."""
|
|
164
|
+
|
|
165
|
+
def redirect_request(
|
|
166
|
+
self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str
|
|
167
|
+
) -> Any:
|
|
168
|
+
new = super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
169
|
+
old_parts = urllib.parse.urlparse(req.full_url)
|
|
170
|
+
new_parts = urllib.parse.urlparse(newurl)
|
|
171
|
+
if new is not None and (
|
|
172
|
+
new_parts.netloc != old_parts.netloc or new_parts.scheme != old_parts.scheme
|
|
173
|
+
):
|
|
174
|
+
for header in ("Authorization", "authorization"):
|
|
175
|
+
new.headers.pop(header, None)
|
|
176
|
+
new.unredirected_hdrs.pop(header, None)
|
|
177
|
+
return new
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# The one opener every API call shares — a redirect that changes host loses
|
|
181
|
+
# the Authorization header instead of forwarding the credential.
|
|
182
|
+
AUTH_SAFE_OPENER = urllib.request.build_opener(_NoAuthRedirect)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _raw_transport(request: urllib.request.Request) -> str:
|
|
186
|
+
"""Fetch a non-JSON body (the diff media type returns text/plain)."""
|
|
187
|
+
try:
|
|
188
|
+
with AUTH_SAFE_OPENER.open(request, timeout=30) as response:
|
|
189
|
+
return str(response.read().decode(errors="replace"))
|
|
190
|
+
except urllib.error.HTTPError as exc:
|
|
191
|
+
body = exc.read().decode(errors="replace")[:500]
|
|
192
|
+
raise GitHubError(exc.code, urllib.parse.urlparse(request.full_url).path, body) from None
|
|
193
|
+
except urllib.error.URLError as exc:
|
|
194
|
+
raise GitHubError(
|
|
195
|
+
0, urllib.parse.urlparse(request.full_url).path, str(exc.reason)
|
|
196
|
+
) from None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _default_transport(request: urllib.request.Request) -> Any:
|
|
200
|
+
try:
|
|
201
|
+
with AUTH_SAFE_OPENER.open(request, timeout=30) as response:
|
|
202
|
+
payload = response.read()
|
|
203
|
+
except urllib.error.HTTPError as exc:
|
|
204
|
+
body = exc.read().decode(errors="replace")[:500]
|
|
205
|
+
raise GitHubError(exc.code, urllib.parse.urlparse(request.full_url).path, body) from None
|
|
206
|
+
except urllib.error.URLError as exc:
|
|
207
|
+
raise GitHubError(
|
|
208
|
+
0, urllib.parse.urlparse(request.full_url).path, str(exc.reason)
|
|
209
|
+
) from None
|
|
210
|
+
return json.loads(payload) if payload else None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@dataclass
|
|
214
|
+
class GitHubClient:
|
|
215
|
+
"""Minimal REST surface the orchestrator needs. Mutations honor dry_run."""
|
|
216
|
+
|
|
217
|
+
auth: TokenProvider
|
|
218
|
+
transport: Transport = field(default=_default_transport)
|
|
219
|
+
raw_transport: Callable[[urllib.request.Request], str] = field(default=_raw_transport)
|
|
220
|
+
dry_run: bool = False
|
|
221
|
+
|
|
222
|
+
def _request(self, method: str, path: str, body: dict[str, Any] | None = None) -> Any:
|
|
223
|
+
request = urllib.request.Request(
|
|
224
|
+
f"{API}{path}",
|
|
225
|
+
method=method,
|
|
226
|
+
data=json.dumps(body).encode() if body is not None else None,
|
|
227
|
+
headers={
|
|
228
|
+
"Authorization": f"Bearer {self.auth.token()}",
|
|
229
|
+
"Accept": "application/vnd.github+json",
|
|
230
|
+
"Content-Type": "application/json",
|
|
231
|
+
},
|
|
232
|
+
)
|
|
233
|
+
return self.transport(request)
|
|
234
|
+
|
|
235
|
+
@staticmethod
|
|
236
|
+
def _expect_dict(data: Any, path: str) -> dict[str, Any]:
|
|
237
|
+
if not isinstance(data, dict):
|
|
238
|
+
raise GitHubError(200, path, f"expected an object, got {type(data).__name__}")
|
|
239
|
+
return data
|
|
240
|
+
|
|
241
|
+
def default_branch(self, repo: str) -> str:
|
|
242
|
+
path = f"/repos/{urllib.parse.quote(repo)}"
|
|
243
|
+
return str(self._expect_dict(self._request("GET", path), path)["default_branch"])
|
|
244
|
+
|
|
245
|
+
def get_file(self, repo: str, path: str, ref: str) -> str:
|
|
246
|
+
"""Fetch a file's text at a ref — used to read contracts from the
|
|
247
|
+
default branch, never from PR branches."""
|
|
248
|
+
query = urllib.parse.urlencode({"ref": ref})
|
|
249
|
+
api_path = f"/repos/{urllib.parse.quote(repo)}/contents/{urllib.parse.quote(path)}?{query}"
|
|
250
|
+
data = self._expect_dict(self._request("GET", api_path), api_path)
|
|
251
|
+
if data.get("type") != "file":
|
|
252
|
+
raise GitHubError(200, api_path, f"not a file (type={data.get('type')!r})")
|
|
253
|
+
if data.get("encoding") != "base64":
|
|
254
|
+
raise GitHubError(200, api_path, f"unreadable encoding {data.get('encoding')!r}")
|
|
255
|
+
return base64.b64decode(data["content"]).decode()
|
|
256
|
+
|
|
257
|
+
def create_pr(self, repo: str, head: str, base: str, title: str, body: str) -> int | None:
|
|
258
|
+
if self.dry_run:
|
|
259
|
+
log.info("[dry-run] create PR %s: %s <- %s (%r)", repo, base, head, title)
|
|
260
|
+
return None
|
|
261
|
+
path = f"/repos/{urllib.parse.quote(repo)}/pulls"
|
|
262
|
+
data = self._expect_dict(
|
|
263
|
+
self._request("POST", path, {"title": title, "head": head, "base": base, "body": body}),
|
|
264
|
+
path,
|
|
265
|
+
)
|
|
266
|
+
if "number" not in data:
|
|
267
|
+
raise GitHubError(200, path, f"no PR number in response: {data.get('message')}")
|
|
268
|
+
return int(data["number"])
|
|
269
|
+
|
|
270
|
+
def get_issue(self, repo: str, number: int) -> dict[str, Any]:
|
|
271
|
+
path = f"/repos/{urllib.parse.quote(repo)}/issues/{number}"
|
|
272
|
+
return self._expect_dict(self._request("GET", path), path)
|
|
273
|
+
|
|
274
|
+
def list_open_issues(self, repo: str, max_pages: int = 3) -> list[dict[str, Any]]:
|
|
275
|
+
"""Open issues (PRs excluded — the issues API mixes them in)."""
|
|
276
|
+
items = self._paginate(f"/repos/{urllib.parse.quote(repo)}/issues", max_pages)
|
|
277
|
+
return [i for i in items if "pull_request" not in i]
|
|
278
|
+
|
|
279
|
+
def create_pull(
|
|
280
|
+
self, repo: str, title: str, head: str, base: str, body: str, draft: bool = False
|
|
281
|
+
) -> str:
|
|
282
|
+
"""Open a pull request; returns its html url."""
|
|
283
|
+
if self.dry_run:
|
|
284
|
+
log.info("[dry-run] PR on %s: %s (%s -> %s)", repo, title, head, base)
|
|
285
|
+
return f"https://github.com/{repo}/pull/dry-run"
|
|
286
|
+
path = f"/repos/{urllib.parse.quote(repo)}/pulls"
|
|
287
|
+
data = self._request(
|
|
288
|
+
"POST",
|
|
289
|
+
path,
|
|
290
|
+
{"title": title, "head": head, "base": base, "body": body, "draft": draft},
|
|
291
|
+
)
|
|
292
|
+
if not isinstance(data, dict) or "html_url" not in data:
|
|
293
|
+
raise GitHubError(0, path, f"unexpected create_pull response: {str(data)[:200]}")
|
|
294
|
+
return str(data["html_url"])
|
|
295
|
+
|
|
296
|
+
def get_pull_request(self, repo: str, number: int) -> dict[str, Any]:
|
|
297
|
+
path = f"/repos/{urllib.parse.quote(repo)}/pulls/{number}"
|
|
298
|
+
return self._expect_dict(self._request("GET", path), path)
|
|
299
|
+
|
|
300
|
+
def find_open_pull_for_head(
|
|
301
|
+
self, repo: str, head_branch: str, base: str
|
|
302
|
+
) -> dict[str, Any] | None:
|
|
303
|
+
"""The OPEN PR from `head_branch` INTO `base` (owner:branch, base-scoped
|
|
304
|
+
so a same-branch PR to a different base is never matched), or None.
|
|
305
|
+
Returns the raw PR dict (`html_url`, `number`, `draft`, …). Used for
|
|
306
|
+
idempotency: a wake that died after opening the PR but before recording
|
|
307
|
+
it reconciles to that PR instead of re-pushing (non-fast-forward) and
|
|
308
|
+
opening a duplicate."""
|
|
309
|
+
owner = repo.split("/")[0]
|
|
310
|
+
query = urllib.parse.urlencode(
|
|
311
|
+
{"head": f"{owner}:{head_branch}", "base": base, "state": "open"}
|
|
312
|
+
)
|
|
313
|
+
path = f"/repos/{urllib.parse.quote(repo)}/pulls?{query}"
|
|
314
|
+
data = self._request("GET", path)
|
|
315
|
+
if isinstance(data, list):
|
|
316
|
+
for item in data:
|
|
317
|
+
if isinstance(item, dict) and item.get("html_url"):
|
|
318
|
+
return item
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
BODY_EDIT_MARKER = marker("body-edit")
|
|
322
|
+
# the orchestrator-owned candidate row in pr_body's results table
|
|
323
|
+
# \r-tolerant: a human web-UI edit can normalize the body to CRLF
|
|
324
|
+
_CANDIDATE_ROW = re.compile(r"^\| candidate \| .* \|(\r?)$", re.MULTILINE)
|
|
325
|
+
|
|
326
|
+
def update_candidate_row(
|
|
327
|
+
self, repo: str, number: int, candidate: float, digits: int | None = None
|
|
328
|
+
) -> bool:
|
|
329
|
+
"""Rewrite the results table's candidate row in place (PATCH).
|
|
330
|
+
|
|
331
|
+
The row is orchestrator-owned and mechanical — the ONE part of the
|
|
332
|
+
body that must never go stale when a follow-up push re-measures
|
|
333
|
+
(rewrite the measured numbers,
|
|
334
|
+
never the narrative). Returns False when the row is not found
|
|
335
|
+
(older body formats), in which case the Edit addendum still
|
|
336
|
+
carries the number.
|
|
337
|
+
"""
|
|
338
|
+
if self.dry_run:
|
|
339
|
+
log.info("[dry-run] update candidate row %s#%d -> %s", repo, number, candidate)
|
|
340
|
+
return True
|
|
341
|
+
path = f"/repos/{urllib.parse.quote(repo)}/pulls/{number}"
|
|
342
|
+
current = str(self._expect_dict(self._request("GET", path), path).get("body") or "")
|
|
343
|
+
# only the FIRST match, and only in the orchestrator's preamble
|
|
344
|
+
# (before the report section) — agent report text could contain a
|
|
345
|
+
# lookalike row, and it must stay untouched
|
|
346
|
+
head, sep, tail = current.partition("## Research report")
|
|
347
|
+
if not sep:
|
|
348
|
+
# No report heading -> the preamble boundary is gone (human
|
|
349
|
+
# body edit?): fail CLOSED rather than rewrite report text —
|
|
350
|
+
# the Edit addendum already carries the number.
|
|
351
|
+
return False
|
|
352
|
+
if not self._CANDIDATE_ROW.search(head):
|
|
353
|
+
return False
|
|
354
|
+
from outerloop.progress import fmt_metric
|
|
355
|
+
|
|
356
|
+
head = self._CANDIDATE_ROW.sub(
|
|
357
|
+
rf"| candidate | {fmt_metric(candidate, digits)} |\1", head, count=1
|
|
358
|
+
)
|
|
359
|
+
self._request("PATCH", path, {"body": f"{head}{sep}{tail}"})
|
|
360
|
+
return True
|
|
361
|
+
|
|
362
|
+
def append_pull_body(self, repo: str, number: int, addendum: str) -> None:
|
|
363
|
+
"""Upsert an EDIT addendum onto a PR body (read-modify-write PATCH).
|
|
364
|
+
|
|
365
|
+
Follow-up commits desync the report frozen into the body at publish.
|
|
366
|
+
The addendum marks the body EDITED rather than rewriting history in
|
|
367
|
+
place — and
|
|
368
|
+
REPLACES any previous addendum (marker-delimited) instead of
|
|
369
|
+
stacking one per round, so the body stays bounded and always points
|
|
370
|
+
at the latest state.
|
|
371
|
+
"""
|
|
372
|
+
if self.dry_run:
|
|
373
|
+
log.info("[dry-run] upsert PR body edit %s#%d (%d chars)", repo, number, len(addendum))
|
|
374
|
+
return
|
|
375
|
+
path = f"/repos/{urllib.parse.quote(repo)}/pulls/{number}"
|
|
376
|
+
current = str(self._expect_dict(self._request("GET", path), path).get("body") or "")
|
|
377
|
+
# Strip ONLY a previous addendum of ours: marker followed by our
|
|
378
|
+
# exact format. Agent-authored report text could contain the marker
|
|
379
|
+
# string (it is public), and splitting on it blindly would truncate
|
|
380
|
+
# the frozen report — the history this method exists to preserve.
|
|
381
|
+
base = current
|
|
382
|
+
# a previous addendum of ours may carry the pre-rename marker
|
|
383
|
+
for old in (self.BODY_EDIT_MARKER, legacy_marker("body-edit")):
|
|
384
|
+
idx = current.rfind(old)
|
|
385
|
+
if idx != -1 and current[idx + len(old) :].lstrip().startswith("---"):
|
|
386
|
+
base = current[:idx].rstrip()
|
|
387
|
+
break
|
|
388
|
+
self._request("PATCH", path, {"body": f"{base}\n\n{self.BODY_EDIT_MARKER}\n{addendum}"})
|
|
389
|
+
|
|
390
|
+
def _graphql(self, query: str, variables: dict[str, Any]) -> dict[str, Any]:
|
|
391
|
+
data = self._request("POST", "/graphql", {"query": query, "variables": variables})
|
|
392
|
+
if not isinstance(data, dict):
|
|
393
|
+
raise GitHubError(0, "/graphql", f"expected an object, got {type(data).__name__}")
|
|
394
|
+
# GraphQL reports failures inside a 200; status 0 keeps callers'
|
|
395
|
+
# retry/permanence classification honest (this is not an HTTP 200
|
|
396
|
+
# success and not an HTTP error either).
|
|
397
|
+
if data.get("errors"):
|
|
398
|
+
raise GitHubError(0, "/graphql", str(data["errors"])[:300])
|
|
399
|
+
inner = data.get("data")
|
|
400
|
+
return inner if isinstance(inner, dict) else {}
|
|
401
|
+
|
|
402
|
+
def review_decision(self, repo: str, number: int) -> str:
|
|
403
|
+
"""The PR's reviewDecision ("REVIEW_REQUIRED", "APPROVED",
|
|
404
|
+
"CHANGES_REQUESTED", or "" when the base branch requires no
|
|
405
|
+
reviews). GraphQL-only; readable with normal read permissions."""
|
|
406
|
+
owner, _, name = repo.partition("/")
|
|
407
|
+
query = (
|
|
408
|
+
"query($owner: String!, $name: String!, $number: Int!) {"
|
|
409
|
+
" repository(owner: $owner, name: $name) {"
|
|
410
|
+
" pullRequest(number: $number) { reviewDecision } } }"
|
|
411
|
+
)
|
|
412
|
+
data = self._graphql(query, {"owner": owner, "name": name, "number": number})
|
|
413
|
+
pr = (data.get("repository") or {}).get("pullRequest") or {}
|
|
414
|
+
return str(pr.get("reviewDecision") or "")
|
|
415
|
+
|
|
416
|
+
def allowed_merge_methods(self, repo: str) -> list[str]:
|
|
417
|
+
"""Merge methods the repo permits, in our preference order (merge
|
|
418
|
+
commits first — trailer/provenance-preserving)."""
|
|
419
|
+
path = f"/repos/{urllib.parse.quote(repo)}"
|
|
420
|
+
settings = self._expect_dict(self._request("GET", path), path)
|
|
421
|
+
order = [
|
|
422
|
+
("MERGE", "allow_merge_commit"),
|
|
423
|
+
("SQUASH", "allow_squash_merge"),
|
|
424
|
+
("REBASE", "allow_rebase_merge"),
|
|
425
|
+
]
|
|
426
|
+
return [m for m, key in order if settings.get(key)]
|
|
427
|
+
|
|
428
|
+
def enable_auto_merge(
|
|
429
|
+
self, repo: str, number: int, method: str = "MERGE", expected_head: str = ""
|
|
430
|
+
) -> None:
|
|
431
|
+
"""Arm GitHub's auto-merge on a PR (a GraphQL-only capability).
|
|
432
|
+
`expected_head` binds the arm to that head oid: GitHub refuses when
|
|
433
|
+
the PR head has moved, so a push racing the caller's check can never
|
|
434
|
+
be the thing that merges.
|
|
435
|
+
|
|
436
|
+
Arming does not merge anything: it hands the merge to whatever
|
|
437
|
+
branch protection still requires. Callers that must preserve the
|
|
438
|
+
bot-never-merges rule should use arm_auto_merge_when_review_required
|
|
439
|
+
instead of calling this directly. Repos that also run the follow-up
|
|
440
|
+
lane should have dismiss-stale-reviews enabled, so a bot push after
|
|
441
|
+
arming re-requires a human look instead of merging unseen code.
|
|
442
|
+
"""
|
|
443
|
+
if self.dry_run:
|
|
444
|
+
log.info("[dry-run] arm auto-merge on %s#%d", repo, number)
|
|
445
|
+
return
|
|
446
|
+
pr_path = f"/repos/{urllib.parse.quote(repo)}/pulls/{number}"
|
|
447
|
+
node_id = self.get_pull_request(repo, number).get("node_id")
|
|
448
|
+
if not node_id:
|
|
449
|
+
raise GitHubError(0, pr_path, "no node_id in PR payload")
|
|
450
|
+
variables: dict[str, Any] = {"pr": str(node_id), "method": method}
|
|
451
|
+
if expected_head:
|
|
452
|
+
mutation = (
|
|
453
|
+
"mutation($pr: ID!, $method: PullRequestMergeMethod!, $head: GitObjectID!) {"
|
|
454
|
+
" enablePullRequestAutoMerge(input: {pullRequestId: $pr, mergeMethod: $method,"
|
|
455
|
+
" expectedHeadOid: $head}) { pullRequest { number } } }"
|
|
456
|
+
)
|
|
457
|
+
variables["head"] = expected_head
|
|
458
|
+
else:
|
|
459
|
+
mutation = (
|
|
460
|
+
"mutation($pr: ID!, $method: PullRequestMergeMethod!) {"
|
|
461
|
+
" enablePullRequestAutoMerge(input: {pullRequestId: $pr, mergeMethod: $method})"
|
|
462
|
+
" { pullRequest { number } } }"
|
|
463
|
+
)
|
|
464
|
+
self._graphql(mutation, variables)
|
|
465
|
+
|
|
466
|
+
def arm_auto_merge_when_review_required(self, repo: str, number: int) -> bool:
|
|
467
|
+
"""Arm auto-merge ONLY when branch protection makes a human review
|
|
468
|
+
the missing condition (reviewDecision == REVIEW_REQUIRED).
|
|
469
|
+
|
|
470
|
+
On a repo without required reviews, arming would merge the bot's own
|
|
471
|
+
PR the moment CI is green — no human ever acts. That would break the
|
|
472
|
+
bot-never-merges rule via nothing but per-repo config drift, so the
|
|
473
|
+
guard lives here, in code. The merge method falls back through what
|
|
474
|
+
the repo allows (merge-commit preferred: it preserves the Agent
|
|
475
|
+
trailers and commit sequence as provenance)."""
|
|
476
|
+
decision = self.review_decision(repo, number)
|
|
477
|
+
if decision != "REVIEW_REQUIRED":
|
|
478
|
+
log.warning(
|
|
479
|
+
"not arming auto-merge on %s#%d: reviewDecision=%r "
|
|
480
|
+
"(no required human review would stand between arming and merging)",
|
|
481
|
+
repo,
|
|
482
|
+
number,
|
|
483
|
+
decision,
|
|
484
|
+
)
|
|
485
|
+
return False
|
|
486
|
+
methods = self.allowed_merge_methods(repo) or ["MERGE"]
|
|
487
|
+
self.enable_auto_merge(repo, number, method=methods[0])
|
|
488
|
+
return True
|
|
489
|
+
|
|
490
|
+
def disable_auto_merge(self, repo: str, number: int) -> bool:
|
|
491
|
+
"""Disarm GitHub auto-merge (GraphQL). The follow-up lane calls this
|
|
492
|
+
before pushing new commits to an auto-mode PR: an armed PR would
|
|
493
|
+
merge the NEW head on green CI without a fresh gate/suite/panel
|
|
494
|
+
(terra #171). Returns False when nothing was armed or on error."""
|
|
495
|
+
if self.dry_run:
|
|
496
|
+
log.info("[dry-run] disarm auto-merge on %s#%d", repo, number)
|
|
497
|
+
return True
|
|
498
|
+
try:
|
|
499
|
+
node_id = self.get_pull_request(repo, number).get("node_id")
|
|
500
|
+
if not node_id:
|
|
501
|
+
return False
|
|
502
|
+
mutation = (
|
|
503
|
+
"mutation($pr: ID!) {"
|
|
504
|
+
" disablePullRequestAutoMerge(input: {pullRequestId: $pr})"
|
|
505
|
+
" { pullRequest { number } } }"
|
|
506
|
+
)
|
|
507
|
+
self._graphql(mutation, {"pr": str(node_id)})
|
|
508
|
+
return True
|
|
509
|
+
except GitHubError as exc:
|
|
510
|
+
if "not enabled" in str(exc).casefold():
|
|
511
|
+
# nothing was armed — the state we wanted; pushing is safe
|
|
512
|
+
return True
|
|
513
|
+
log.warning("auto-merge disarm on %s#%s failed: %s", repo, number, exc)
|
|
514
|
+
return False
|
|
515
|
+
|
|
516
|
+
def merge_pull(
|
|
517
|
+
self, repo: str, number: int, method: str = "merge", expected_head: str = ""
|
|
518
|
+
) -> bool:
|
|
519
|
+
"""Directly merge a pull request (REST). Used only by AUTO merge mode
|
|
520
|
+
when nothing is pending for auto-merge to arm against. `expected_head`
|
|
521
|
+
rides the API's `sha` guard: GitHub refuses (409) when the head moved
|
|
522
|
+
since the caller checked it."""
|
|
523
|
+
if self.dry_run:
|
|
524
|
+
log.info("[dry-run] merge %s#%s", repo, number)
|
|
525
|
+
return True
|
|
526
|
+
body: dict[str, Any] = {"merge_method": method}
|
|
527
|
+
if expected_head:
|
|
528
|
+
body["sha"] = expected_head
|
|
529
|
+
try:
|
|
530
|
+
self._request(
|
|
531
|
+
"PUT",
|
|
532
|
+
f"/repos/{urllib.parse.quote(repo)}/pulls/{number}/merge",
|
|
533
|
+
body,
|
|
534
|
+
)
|
|
535
|
+
return True
|
|
536
|
+
except GitHubError as exc:
|
|
537
|
+
log.warning("direct merge of %s#%s failed: %s", repo, number, exc)
|
|
538
|
+
return False
|
|
539
|
+
|
|
540
|
+
def arm_auto_merge_auto_mode(self, repo: str, number: int, expected_head: str = "") -> bool:
|
|
541
|
+
"""AUTO merge mode (the contract's `merge: auto` dial): arm
|
|
542
|
+
auto-merge so the PR merges when its required checks pass; when
|
|
543
|
+
GitHub declines ONLY because nothing is pending (clean status),
|
|
544
|
+
merge directly. Any other decline — auto-merge disabled in repo
|
|
545
|
+
settings, missing permission — is a repo-owner control and STOPS
|
|
546
|
+
here (terra #171: the broad fallback would have bulldozed a
|
|
547
|
+
deliberately disabled auto-merge setting). The manual-mode
|
|
548
|
+
review-required guard deliberately does not apply — the owner
|
|
549
|
+
opted this repo in, and the gate/panel bound before publish."""
|
|
550
|
+
methods = self.allowed_merge_methods(repo) or ["MERGE"]
|
|
551
|
+
try:
|
|
552
|
+
self.enable_auto_merge(repo, number, method=methods[0], expected_head=expected_head)
|
|
553
|
+
return True
|
|
554
|
+
except GitHubError as exc:
|
|
555
|
+
if "clean status" not in str(exc).casefold():
|
|
556
|
+
log.warning(
|
|
557
|
+
"auto-merge arming on %s#%s failed (%s); NOT merging "
|
|
558
|
+
"directly — the decline may be a repo-owner control",
|
|
559
|
+
repo,
|
|
560
|
+
number,
|
|
561
|
+
exc,
|
|
562
|
+
)
|
|
563
|
+
return False
|
|
564
|
+
log.info(
|
|
565
|
+
"auto-merge arming on %s#%s: PR already clean; merging directly",
|
|
566
|
+
repo,
|
|
567
|
+
number,
|
|
568
|
+
)
|
|
569
|
+
return self.merge_pull(repo, number, method=methods[0].lower(), expected_head=expected_head)
|
|
570
|
+
|
|
571
|
+
def get_pull_request_diff(self, repo: str, number: int) -> str:
|
|
572
|
+
"""Fetch a PR's unified diff (uses the diff media type)."""
|
|
573
|
+
path = f"/repos/{urllib.parse.quote(repo)}/pulls/{number}"
|
|
574
|
+
request = urllib.request.Request(
|
|
575
|
+
f"{API}{path}",
|
|
576
|
+
method="GET",
|
|
577
|
+
headers={
|
|
578
|
+
"Authorization": f"Bearer {self.auth.token()}",
|
|
579
|
+
"Accept": "application/vnd.github.v3.diff",
|
|
580
|
+
},
|
|
581
|
+
)
|
|
582
|
+
return self.raw_transport(request)
|
|
583
|
+
|
|
584
|
+
def get_pull_request_files(
|
|
585
|
+
self, repo: str, number: int, max_pages: int = 5
|
|
586
|
+
) -> list[dict[str, Any]]:
|
|
587
|
+
"""Changed files of a PR (filename, status), following pagination."""
|
|
588
|
+
out: list[dict[str, Any]] = []
|
|
589
|
+
for page in range(1, max_pages + 1):
|
|
590
|
+
path = (
|
|
591
|
+
f"/repos/{urllib.parse.quote(repo)}/pulls/{number}/files?per_page=100&page={page}"
|
|
592
|
+
)
|
|
593
|
+
data = self._request("GET", path)
|
|
594
|
+
if not isinstance(data, list) or not data:
|
|
595
|
+
break
|
|
596
|
+
out.extend(item for item in data if isinstance(item, dict))
|
|
597
|
+
if len(data) < 100:
|
|
598
|
+
break
|
|
599
|
+
return out
|
|
600
|
+
|
|
601
|
+
def list_directory(self, repo: str, path: str, ref: str) -> list[dict[str, Any]]:
|
|
602
|
+
"""Entries of a directory at a ref ([] when absent or not a dir).
|
|
603
|
+
|
|
604
|
+
ONE request, deliberately: the contents API returns the whole
|
|
605
|
+
listing for a directory (capped ~1,000 entries) and IGNORES
|
|
606
|
+
page/per_page — a pagination loop would re-fetch the same list and
|
|
607
|
+
accumulate duplicates. Trees larger than the cap need the git
|
|
608
|
+
trees API; callers here read a handful of entries.
|
|
609
|
+
"""
|
|
610
|
+
query = urllib.parse.urlencode({"ref": ref})
|
|
611
|
+
api_path = f"/repos/{urllib.parse.quote(repo)}/contents/{urllib.parse.quote(path)}?{query}"
|
|
612
|
+
try:
|
|
613
|
+
data = self._request("GET", api_path)
|
|
614
|
+
except GitHubError as exc:
|
|
615
|
+
if exc.status == 404:
|
|
616
|
+
return []
|
|
617
|
+
raise
|
|
618
|
+
return [item for item in data if isinstance(item, dict)] if isinstance(data, list) else []
|
|
619
|
+
|
|
620
|
+
def get_file_content(self, repo: str, path: str, ref: str) -> str | None:
|
|
621
|
+
"""A file's text at `ref`, or None when it can't be provided.
|
|
622
|
+
|
|
623
|
+
Best-effort by design (reviewer context): directories, submodules,
|
|
624
|
+
files over the API's inline limit, and missing paths all return None
|
|
625
|
+
rather than raising.
|
|
626
|
+
"""
|
|
627
|
+
api_path = (
|
|
628
|
+
f"/repos/{urllib.parse.quote(repo)}/contents/"
|
|
629
|
+
f"{urllib.parse.quote(path)}?ref={urllib.parse.quote(ref)}"
|
|
630
|
+
)
|
|
631
|
+
try:
|
|
632
|
+
data = self._request("GET", api_path)
|
|
633
|
+
except GitHubError as exc:
|
|
634
|
+
if exc.status != 404: # 404 = expected (path gone); the rest deserve a trace
|
|
635
|
+
log.warning("context fetch failed for %s@%s: %s", path, ref, exc)
|
|
636
|
+
return None
|
|
637
|
+
if not isinstance(data, dict) or data.get("encoding") != "base64":
|
|
638
|
+
return None
|
|
639
|
+
try:
|
|
640
|
+
raw = base64.b64decode(data.get("content", ""))
|
|
641
|
+
if b"\x00" in raw:
|
|
642
|
+
return None # binary
|
|
643
|
+
return raw.decode("utf-8")
|
|
644
|
+
except (ValueError, TypeError, UnicodeDecodeError):
|
|
645
|
+
return None
|
|
646
|
+
|
|
647
|
+
def list_comments(
|
|
648
|
+
self, repo: str, issue_number: int, max_pages: int = 20
|
|
649
|
+
) -> list[dict[str, Any]]:
|
|
650
|
+
"""All comments on an issue/PR, following pagination."""
|
|
651
|
+
comments: list[dict[str, Any]] = []
|
|
652
|
+
for page in range(1, max_pages + 1):
|
|
653
|
+
path = (
|
|
654
|
+
f"/repos/{urllib.parse.quote(repo)}/issues/{issue_number}"
|
|
655
|
+
f"/comments?per_page=100&page={page}"
|
|
656
|
+
)
|
|
657
|
+
data = self._request("GET", path)
|
|
658
|
+
if not isinstance(data, list) or not data:
|
|
659
|
+
break
|
|
660
|
+
comments.extend(item for item in data if isinstance(item, dict))
|
|
661
|
+
if len(data) < 100:
|
|
662
|
+
break
|
|
663
|
+
return comments
|
|
664
|
+
|
|
665
|
+
def list_pr_reviews(self, repo: str, number: int, max_pages: int = 10) -> list[dict[str, Any]]:
|
|
666
|
+
"""Top-level PR reviews (the 'Review changes' submissions)."""
|
|
667
|
+
return self._paginate(
|
|
668
|
+
f"/repos/{urllib.parse.quote(repo)}/pulls/{number}/reviews", max_pages
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
def list_pr_review_comments(
|
|
672
|
+
self, repo: str, number: int, max_pages: int = 10
|
|
673
|
+
) -> list[dict[str, Any]]:
|
|
674
|
+
"""Inline (Files changed) review comments."""
|
|
675
|
+
return self._paginate(
|
|
676
|
+
f"/repos/{urllib.parse.quote(repo)}/pulls/{number}/comments", max_pages
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
def _paginate(self, base_path: str, max_pages: int) -> list[dict[str, Any]]:
|
|
680
|
+
items: list[dict[str, Any]] = []
|
|
681
|
+
for page in range(1, max_pages + 1):
|
|
682
|
+
data = self._request("GET", f"{base_path}?per_page=100&page={page}")
|
|
683
|
+
if not isinstance(data, list) or not data:
|
|
684
|
+
break
|
|
685
|
+
items.extend(item for item in data if isinstance(item, dict))
|
|
686
|
+
if len(data) < 100:
|
|
687
|
+
break
|
|
688
|
+
return items
|
|
689
|
+
|
|
690
|
+
def upsert_comment(self, repo: str, issue_number: int, marker: str, body: str) -> None:
|
|
691
|
+
"""Post the comment, or edit the existing one carrying `marker`.
|
|
692
|
+
|
|
693
|
+
Keeps the reviewer to one thread per PR however many times it runs.
|
|
694
|
+
"""
|
|
695
|
+
if self.dry_run:
|
|
696
|
+
log.info("[dry-run] upsert comment on %s#%s (%d chars)", repo, issue_number, len(body))
|
|
697
|
+
return
|
|
698
|
+
for comment in self.list_comments(repo, issue_number):
|
|
699
|
+
# Only ever edit a bot's own comment: a human who quote-replies
|
|
700
|
+
# copies the marker, and overwriting their text would be worse
|
|
701
|
+
# than posting a second thread.
|
|
702
|
+
author_type = str((comment.get("user") or {}).get("type", ""))
|
|
703
|
+
if author_type.casefold() != "bot":
|
|
704
|
+
continue
|
|
705
|
+
if marker in str(comment.get("body", "")):
|
|
706
|
+
path = f"/repos/{urllib.parse.quote(repo)}/issues/comments/{int(comment['id'])}"
|
|
707
|
+
self._request("PATCH", path, {"body": body})
|
|
708
|
+
return
|
|
709
|
+
self.comment(repo, issue_number, body)
|
|
710
|
+
|
|
711
|
+
def create_pr_review(
|
|
712
|
+
self,
|
|
713
|
+
repo: str,
|
|
714
|
+
number: int,
|
|
715
|
+
body: str,
|
|
716
|
+
comments: list[dict[str, Any]] | None = None,
|
|
717
|
+
) -> None:
|
|
718
|
+
"""Post a PR review with optional inline comments.
|
|
719
|
+
|
|
720
|
+
The event is hard-coded to COMMENT: this client must never be able
|
|
721
|
+
to approve or request changes — a Write-role bot review that blocks
|
|
722
|
+
or endorses would hand a model role the exact powers the
|
|
723
|
+
constitution denies it.
|
|
724
|
+
"""
|
|
725
|
+
if self.dry_run:
|
|
726
|
+
log.info(
|
|
727
|
+
"[dry-run] review on %s#%s (%d chars, %d inline)",
|
|
728
|
+
repo,
|
|
729
|
+
number,
|
|
730
|
+
len(body),
|
|
731
|
+
len(comments or []),
|
|
732
|
+
)
|
|
733
|
+
return
|
|
734
|
+
payload: dict[str, Any] = {"body": body, "event": "COMMENT"}
|
|
735
|
+
if comments:
|
|
736
|
+
payload["comments"] = comments
|
|
737
|
+
self._request(
|
|
738
|
+
"POST",
|
|
739
|
+
f"/repos/{urllib.parse.quote(repo)}/pulls/{number}/reviews",
|
|
740
|
+
payload,
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
def create_issue(self, repo: str, title: str, body: str) -> int:
|
|
744
|
+
"""Open an issue; returns its number (0 in dry-run)."""
|
|
745
|
+
if self.dry_run:
|
|
746
|
+
log.info("[dry-run] issue on %s: %s", repo, title)
|
|
747
|
+
return 0
|
|
748
|
+
data = self._request(
|
|
749
|
+
"POST",
|
|
750
|
+
f"/repos/{urllib.parse.quote(repo)}/issues",
|
|
751
|
+
{"title": title, "body": body},
|
|
752
|
+
)
|
|
753
|
+
return int(data.get("number", 0)) if isinstance(data, dict) else 0
|
|
754
|
+
|
|
755
|
+
def close_issue(self, repo: str, number: int) -> None:
|
|
756
|
+
if self.dry_run:
|
|
757
|
+
log.info("[dry-run] close issue %s#%s", repo, number)
|
|
758
|
+
return
|
|
759
|
+
self._request(
|
|
760
|
+
"PATCH",
|
|
761
|
+
f"/repos/{urllib.parse.quote(repo)}/issues/{number}",
|
|
762
|
+
{"state": "closed"},
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
def ensure_branch(self, repo: str, branch: str) -> bool:
|
|
766
|
+
"""The branch exists (created from the default branch's head if not).
|
|
767
|
+
Returns False only when creation failed."""
|
|
768
|
+
quoted = urllib.parse.quote(repo)
|
|
769
|
+
try:
|
|
770
|
+
self._request("GET", f"/repos/{quoted}/git/ref/heads/{urllib.parse.quote(branch)}")
|
|
771
|
+
return True
|
|
772
|
+
except GitHubError:
|
|
773
|
+
pass
|
|
774
|
+
if self.dry_run:
|
|
775
|
+
log.info("[dry-run] create branch %s on %s", branch, repo)
|
|
776
|
+
return True
|
|
777
|
+
try:
|
|
778
|
+
default = self.default_branch(repo)
|
|
779
|
+
ref = self._request(
|
|
780
|
+
"GET", f"/repos/{quoted}/git/ref/heads/{urllib.parse.quote(default)}"
|
|
781
|
+
)
|
|
782
|
+
sha = ref["object"]["sha"] if isinstance(ref, dict) else ""
|
|
783
|
+
self._request(
|
|
784
|
+
"POST", f"/repos/{quoted}/git/refs", {"ref": f"refs/heads/{branch}", "sha": sha}
|
|
785
|
+
)
|
|
786
|
+
return True
|
|
787
|
+
except (GitHubError, KeyError, TypeError) as exc:
|
|
788
|
+
log.warning("could not create branch %s on %s: %s", branch, repo, exc)
|
|
789
|
+
return False
|
|
790
|
+
|
|
791
|
+
def put_file(self, repo: str, path: str, content: str, branch: str, message: str) -> str:
|
|
792
|
+
"""Create or update one file on `branch` via the contents API.
|
|
793
|
+
Returns "created" | "updated" ("" on failure) — callers use the
|
|
794
|
+
distinction for idempotency (an update means this artifact was
|
|
795
|
+
already published once)."""
|
|
796
|
+
if self.dry_run:
|
|
797
|
+
log.info("[dry-run] put %s on %s@%s", path, repo, branch)
|
|
798
|
+
return "created"
|
|
799
|
+
quoted = urllib.parse.quote(repo)
|
|
800
|
+
api = f"/repos/{quoted}/contents/{urllib.parse.quote(path)}"
|
|
801
|
+
body: dict[str, Any] = {
|
|
802
|
+
"message": message,
|
|
803
|
+
"content": base64.b64encode(content.encode()).decode(),
|
|
804
|
+
"branch": branch,
|
|
805
|
+
}
|
|
806
|
+
try:
|
|
807
|
+
existing = self._request("GET", f"{api}?ref={urllib.parse.quote(branch)}")
|
|
808
|
+
if isinstance(existing, dict) and existing.get("sha"):
|
|
809
|
+
body["sha"] = existing["sha"]
|
|
810
|
+
except GitHubError:
|
|
811
|
+
pass # new file
|
|
812
|
+
try:
|
|
813
|
+
self._request("PUT", api, body)
|
|
814
|
+
return "updated" if "sha" in body else "created"
|
|
815
|
+
except GitHubError as exc:
|
|
816
|
+
log.warning("could not put %s on %s@%s: %s", path, repo, branch, exc)
|
|
817
|
+
return ""
|
|
818
|
+
|
|
819
|
+
def branch_head(self, repo: str, branch: str) -> str | None:
|
|
820
|
+
"""The branch's current commit sha; "" when the branch does not
|
|
821
|
+
exist (nothing to protect), None on an outage or malformed reply
|
|
822
|
+
(the caller must not write unguarded)."""
|
|
823
|
+
quoted = urllib.parse.quote(repo)
|
|
824
|
+
try:
|
|
825
|
+
ref = self._request(
|
|
826
|
+
"GET", f"/repos/{quoted}/git/ref/heads/{urllib.parse.quote(branch)}"
|
|
827
|
+
)
|
|
828
|
+
return str(ref["object"]["sha"])
|
|
829
|
+
except GitHubError as exc:
|
|
830
|
+
return "" if getattr(exc, "status", None) == 404 else None
|
|
831
|
+
except (KeyError, TypeError):
|
|
832
|
+
return None
|
|
833
|
+
|
|
834
|
+
def put_files(
|
|
835
|
+
self,
|
|
836
|
+
repo: str,
|
|
837
|
+
files: dict[str, str],
|
|
838
|
+
branch: str,
|
|
839
|
+
message: str,
|
|
840
|
+
expected_head: str | None = None,
|
|
841
|
+
) -> bool:
|
|
842
|
+
"""Create or update SEVERAL files on `branch` as ONE commit (git data
|
|
843
|
+
API: blobs -> tree -> commit -> ref). All-or-nothing: True only when
|
|
844
|
+
the ref moved; on failure nothing changed and the caller retries the
|
|
845
|
+
whole batch next pass.
|
|
846
|
+
|
|
847
|
+
Concurrency: pass `expected_head` (the head the caller SNAPSHOTTED
|
|
848
|
+
before reading the files it compared against) and the batch refuses
|
|
849
|
+
when the branch has moved since — a write that landed mid-pass is
|
|
850
|
+
never buried under stale content. A write landing after this check
|
|
851
|
+
still cannot be clobbered: the unforced ref update rejects any
|
|
852
|
+
non-fast-forward."""
|
|
853
|
+
if not files:
|
|
854
|
+
return True
|
|
855
|
+
if self.dry_run:
|
|
856
|
+
log.info("[dry-run] batch put %d file(s) on %s@%s", len(files), repo, branch)
|
|
857
|
+
return True
|
|
858
|
+
quoted = urllib.parse.quote(repo)
|
|
859
|
+
ref_path = f"/repos/{quoted}/git/refs/heads/{urllib.parse.quote(branch)}"
|
|
860
|
+
try:
|
|
861
|
+
ref = self._request(
|
|
862
|
+
"GET", f"/repos/{quoted}/git/ref/heads/{urllib.parse.quote(branch)}"
|
|
863
|
+
)
|
|
864
|
+
base_sha = ref["object"]["sha"]
|
|
865
|
+
if expected_head and base_sha != expected_head:
|
|
866
|
+
log.warning(
|
|
867
|
+
"batch on %s@%s refused: head moved %s -> %s (retry next pass)",
|
|
868
|
+
repo,
|
|
869
|
+
branch,
|
|
870
|
+
expected_head[:9],
|
|
871
|
+
str(base_sha)[:9],
|
|
872
|
+
)
|
|
873
|
+
return False
|
|
874
|
+
base_tree = self._request("GET", f"/repos/{quoted}/git/commits/{base_sha}")["tree"][
|
|
875
|
+
"sha"
|
|
876
|
+
]
|
|
877
|
+
entries = []
|
|
878
|
+
for path, content in sorted(files.items()):
|
|
879
|
+
blob = self._request(
|
|
880
|
+
"POST",
|
|
881
|
+
f"/repos/{quoted}/git/blobs",
|
|
882
|
+
{
|
|
883
|
+
"content": base64.b64encode(content.encode()).decode(),
|
|
884
|
+
"encoding": "base64",
|
|
885
|
+
},
|
|
886
|
+
)
|
|
887
|
+
entries.append({"path": path, "mode": "100644", "type": "blob", "sha": blob["sha"]})
|
|
888
|
+
tree = self._request(
|
|
889
|
+
"POST", f"/repos/{quoted}/git/trees", {"base_tree": base_tree, "tree": entries}
|
|
890
|
+
)
|
|
891
|
+
commit = self._request(
|
|
892
|
+
"POST",
|
|
893
|
+
f"/repos/{quoted}/git/commits",
|
|
894
|
+
{"message": message, "tree": tree["sha"], "parents": [base_sha]},
|
|
895
|
+
)
|
|
896
|
+
self._request("PATCH", ref_path, {"sha": commit["sha"]})
|
|
897
|
+
return True
|
|
898
|
+
except (GitHubError, KeyError, TypeError) as exc:
|
|
899
|
+
log.warning(
|
|
900
|
+
"batched put of %d file(s) on %s@%s failed: %s", len(files), repo, branch, exc
|
|
901
|
+
)
|
|
902
|
+
return False
|
|
903
|
+
|
|
904
|
+
def comment(self, repo: str, issue_number: int, body: str) -> None:
|
|
905
|
+
if self.dry_run:
|
|
906
|
+
log.info("[dry-run] comment on %s#%s (%d chars)", repo, issue_number, len(body))
|
|
907
|
+
return
|
|
908
|
+
path = f"/repos/{urllib.parse.quote(repo)}/issues/{issue_number}/comments"
|
|
909
|
+
self._request("POST", path, {"body": body})
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
_MINIMAL_GIT_CONFIG = b"[core]\n\trepositoryformatversion = 0\n"
|
|
913
|
+
# config sections that can REDIRECT a git operation to attacker-chosen refs
|
|
914
|
+
# or files: url.*.insteadOf rewrites, and include/includeIf that pull in more
|
|
915
|
+
# config (possibly a FIFO that would block git's own parse forever). The
|
|
916
|
+
# session-writable .git/config is the only config source under credential
|
|
917
|
+
# (global/system are /dev/null), so stripping these here disarms the whole
|
|
918
|
+
# class before any git subcommand — local or network — reads the file.
|
|
919
|
+
_REDIRECT_SECTIONS = ("url", "include", "includeif")
|
|
920
|
+
_SECTION_RE = re.compile(r"^\s*\[\s*([A-Za-z0-9.-]+)")
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
# The parts of a workspace's .git the kernel relies on after a session ran.
|
|
924
|
+
# 2026-09-03 an author copied the pack files into the container's private
|
|
925
|
+
# /tmp and symlinked objects/pack there, so the kernel's git on the host
|
|
926
|
+
# found refs with no objects behind them. The guard runs inside every kernel
|
|
927
|
+
# git call (Workspace.git / git_network), so no path can skip it.
|
|
928
|
+
# config and config.worktree are NOT here: _ensure_regular_config sanitizes
|
|
929
|
+
# them in place (a FIFO or symlink config is replaced, not fatal) and runs
|
|
930
|
+
# first, so the guard's own git subprocess never reads a tampered config.
|
|
931
|
+
_GIT_REGULAR_FILES = ("HEAD",) # must exist and be regular files
|
|
932
|
+
_GIT_REGULAR_IF_PRESENT = (
|
|
933
|
+
"index",
|
|
934
|
+
"packed-refs",
|
|
935
|
+
"shallow",
|
|
936
|
+
"MERGE_HEAD",
|
|
937
|
+
"FETCH_HEAD",
|
|
938
|
+
"ORIG_HEAD",
|
|
939
|
+
"COMMIT_EDITMSG",
|
|
940
|
+
"description",
|
|
941
|
+
"objects/info/packs",
|
|
942
|
+
"info/exclude", # the wake writes it; a symlink would carry the write elsewhere
|
|
943
|
+
)
|
|
944
|
+
_GIT_DIRS = ("objects", "refs") # must exist and be directories
|
|
945
|
+
_GIT_DIRS_IF_PRESENT = ("objects/pack", "objects/info", "hooks", "info", "logs", "logs/refs")
|
|
946
|
+
# No symlink may appear among .git's own entries, nor ANYWHERE under the
|
|
947
|
+
# small control trees git writes to or reads from (refs, reflogs, hooks,
|
|
948
|
+
# info): a link there carries a ref update, a reflog append, or a hook read
|
|
949
|
+
# outside the workspace. The object store is checked one level deep plus
|
|
950
|
+
# its pack/info dirs (loose-object trees are large; a missing object is
|
|
951
|
+
# caught by the HEAD readability check below).
|
|
952
|
+
_GIT_NO_SYMLINK_TREES = ("refs", "logs", "hooks", "info")
|
|
953
|
+
_GIT_NO_SYMLINK_ENTRIES = ("", "objects", "objects/info", "objects/pack")
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
GUARD_GIT_TIMEOUT_S = 20 # the guard's own git read; a stall here is a stall everywhere
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def _regular_files_only(git_dir: Path, rel: str, allow_dirs: tuple[str, ...] = ()) -> None:
|
|
960
|
+
"""Every entry of .git/<rel> must be a regular file (directory-entry type,
|
|
961
|
+
no per-file stat); `allow_dirs` names the subdirectories git itself
|
|
962
|
+
creates there (objects/info/commit-graphs for a split commit graph),
|
|
963
|
+
which are checked the same way one level down."""
|
|
964
|
+
try:
|
|
965
|
+
with os.scandir(git_dir / rel) as entries:
|
|
966
|
+
for entry in entries:
|
|
967
|
+
if entry.is_dir(follow_symlinks=False) and entry.name in allow_dirs:
|
|
968
|
+
_regular_files_only(git_dir, f"{rel}/{entry.name}")
|
|
969
|
+
continue
|
|
970
|
+
if not entry.is_file(follow_symlinks=False):
|
|
971
|
+
raise _altered(f".git/{rel}/{entry.name} is not a regular file")
|
|
972
|
+
except PermissionError:
|
|
973
|
+
raise _altered(f".git/{rel} is unreadable") from None
|
|
974
|
+
except FileNotFoundError:
|
|
975
|
+
return
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def _altered(what: str) -> GitError:
|
|
979
|
+
return GitError(f"workspace .git altered by the session: {what}")
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def ensure_regular_git_dir(root: Path | None) -> None:
|
|
983
|
+
"""Refuse a workspace whose .git a session reshaped. `.git` must be a
|
|
984
|
+
real directory (no symlink, no gitdir file); HEAD and config must be
|
|
985
|
+
regular files and the other control files regular when present (a FIFO
|
|
986
|
+
would hang the next git call); objects and refs must be directories, as
|
|
987
|
+
must objects/pack when present; object alternates are never ours. A
|
|
988
|
+
root with no .git at all is left to git's own error."""
|
|
989
|
+
if root is None:
|
|
990
|
+
return
|
|
991
|
+
git_dir = Path(root) / ".git"
|
|
992
|
+
try:
|
|
993
|
+
st = os.lstat(git_dir)
|
|
994
|
+
except OSError:
|
|
995
|
+
return
|
|
996
|
+
if stat.S_ISLNK(st.st_mode):
|
|
997
|
+
raise _altered(".git is a symlink")
|
|
998
|
+
if not stat.S_ISDIR(st.st_mode):
|
|
999
|
+
raise _altered(".git is not a directory (a gitdir file)")
|
|
1000
|
+
_ensure_regular_config(root) # before any git subprocess below reads it
|
|
1001
|
+
# The kernel clones with the files ref backend; a reftable repository
|
|
1002
|
+
# (`git init --ref-format=reftable`, or a `git refs migrate`) keeps its
|
|
1003
|
+
# refs in `.git/reftable/`, where none of the ref reads below would see
|
|
1004
|
+
# them, so an emptied object store could slip past. It is not one the
|
|
1005
|
+
# kernel made: refuse it.
|
|
1006
|
+
if os.path.lexists(git_dir / "reftable"):
|
|
1007
|
+
raise _altered("ref storage is not the files backend (reftable present)")
|
|
1008
|
+
|
|
1009
|
+
def check(rel: str, want_dir: bool, required: bool) -> None:
|
|
1010
|
+
try:
|
|
1011
|
+
st = os.lstat(git_dir / rel)
|
|
1012
|
+
except OSError:
|
|
1013
|
+
if required:
|
|
1014
|
+
raise _altered(f".git/{rel} is missing") from None
|
|
1015
|
+
return
|
|
1016
|
+
if stat.S_ISLNK(st.st_mode):
|
|
1017
|
+
raise _altered(f".git/{rel} is a symlink")
|
|
1018
|
+
if want_dir and not stat.S_ISDIR(st.st_mode):
|
|
1019
|
+
raise _altered(f".git/{rel} is not a directory")
|
|
1020
|
+
if not want_dir and not stat.S_ISREG(st.st_mode):
|
|
1021
|
+
raise _altered(f".git/{rel} is not a regular file")
|
|
1022
|
+
|
|
1023
|
+
for rel in _GIT_REGULAR_FILES:
|
|
1024
|
+
check(rel, want_dir=False, required=True)
|
|
1025
|
+
for rel in _GIT_REGULAR_IF_PRESENT:
|
|
1026
|
+
check(rel, want_dir=False, required=False)
|
|
1027
|
+
for rel in _GIT_DIRS:
|
|
1028
|
+
check(rel, want_dir=True, required=True)
|
|
1029
|
+
for rel in _GIT_DIRS_IF_PRESENT:
|
|
1030
|
+
check(rel, want_dir=True, required=False)
|
|
1031
|
+
for rel in _GIT_NO_SYMLINK_ENTRIES:
|
|
1032
|
+
try:
|
|
1033
|
+
with os.scandir(git_dir / rel if rel else git_dir) as entries:
|
|
1034
|
+
for entry in entries:
|
|
1035
|
+
if entry.is_symlink():
|
|
1036
|
+
shown = f"{rel}/{entry.name}" if rel else entry.name
|
|
1037
|
+
raise _altered(f".git/{shown} is a symlink")
|
|
1038
|
+
except PermissionError:
|
|
1039
|
+
raise _altered(f".git/{rel or '.'} is unreadable") from None
|
|
1040
|
+
except OSError:
|
|
1041
|
+
continue
|
|
1042
|
+
|
|
1043
|
+
def _unreadable(err: OSError) -> None:
|
|
1044
|
+
shown = os.path.relpath(err.filename or "?", git_dir)
|
|
1045
|
+
raise _altered(f".git/{shown} is unreadable") from None
|
|
1046
|
+
|
|
1047
|
+
for rel in _GIT_NO_SYMLINK_TREES:
|
|
1048
|
+
top = git_dir / rel
|
|
1049
|
+
if not top.is_dir():
|
|
1050
|
+
continue
|
|
1051
|
+
for dirpath, dirnames, filenames in os.walk(top, followlinks=False, onerror=_unreadable):
|
|
1052
|
+
for name in (*dirnames, *filenames):
|
|
1053
|
+
full = os.path.join(dirpath, name)
|
|
1054
|
+
if os.path.islink(full):
|
|
1055
|
+
shown = os.path.relpath(full, git_dir)
|
|
1056
|
+
raise _altered(f".git/{shown} is a symlink")
|
|
1057
|
+
for name in filenames:
|
|
1058
|
+
# a FIFO or device where a loose ref, reflog, or hook was would
|
|
1059
|
+
# stall the next git command that scans this tree
|
|
1060
|
+
full = os.path.join(dirpath, name)
|
|
1061
|
+
try:
|
|
1062
|
+
if not stat.S_ISREG(os.lstat(full).st_mode):
|
|
1063
|
+
shown = os.path.relpath(full, git_dir)
|
|
1064
|
+
raise _altered(f".git/{shown} is not a regular file")
|
|
1065
|
+
except FileNotFoundError:
|
|
1066
|
+
continue
|
|
1067
|
+
if os.path.lexists(git_dir / "objects" / "info" / "alternates"):
|
|
1068
|
+
raise _altered("object alternates are present")
|
|
1069
|
+
# Every loose object and pack file must be a regular file: a FIFO in
|
|
1070
|
+
# place of one would stall the next git call that reads it. d_type from
|
|
1071
|
+
# scandir, no per-file stat, so this stays cheap on large stores.
|
|
1072
|
+
objects = git_dir / "objects"
|
|
1073
|
+
try:
|
|
1074
|
+
fanouts = [e for e in os.scandir(objects) if e.is_dir(follow_symlinks=False)]
|
|
1075
|
+
except OSError:
|
|
1076
|
+
fanouts = []
|
|
1077
|
+
for fan in fanouts:
|
|
1078
|
+
if fan.name not in ("pack", "info") and not (
|
|
1079
|
+
len(fan.name) == 2 and _HEX2.fullmatch(fan.name)
|
|
1080
|
+
):
|
|
1081
|
+
raise _altered(f".git/objects/{fan.name} is not a git object directory")
|
|
1082
|
+
_regular_files_only(git_dir, f"objects/{fan.name}", allow_dirs=("commit-graphs",))
|
|
1083
|
+
# The structure can be intact with the objects gone (pack files deleted,
|
|
1084
|
+
# or moved and the link removed): a commit the refs name must still be
|
|
1085
|
+
# readable. HEAD's commit when HEAD is born; otherwise any other ref (a
|
|
1086
|
+
# clone whose remote HEAD names an unpushed branch has an unborn local
|
|
1087
|
+
# HEAD beside real remote refs); a fresh repository with no refs at all
|
|
1088
|
+
# has nothing to check.
|
|
1089
|
+
sha = _head_commit_sha(git_dir) or _any_ref_sha(git_dir)
|
|
1090
|
+
if sha is not None:
|
|
1091
|
+
try:
|
|
1092
|
+
_run_git(
|
|
1093
|
+
["git", "-C", str(root), *SAFE_GIT_FLAGS, "cat-file", "-e", f"{sha}^{{commit}}"],
|
|
1094
|
+
_git_env(None, Path(root)),
|
|
1095
|
+
timeout=GUARD_GIT_TIMEOUT_S, # a stalled read is refused, not waited on
|
|
1096
|
+
)
|
|
1097
|
+
except GitError as exc:
|
|
1098
|
+
if "timed out" in str(exc):
|
|
1099
|
+
raise _altered("the object store did not answer: a git read stalled") from None
|
|
1100
|
+
raise _altered(
|
|
1101
|
+
"HEAD's commit is unreadable: the object store was emptied or moved"
|
|
1102
|
+
) from None
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
_SHA_RE = re.compile(r"[0-9a-f]{40,64}")
|
|
1106
|
+
_HEX2 = re.compile(r"[0-9a-f]{2}")
|
|
1107
|
+
_REF_RE = re.compile(r"refs/[A-Za-z0-9][A-Za-z0-9._/-]{0,200}")
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
PACKED_REFS_MAX_BYTES = 64 * 1024 * 1024 # far beyond any real repository's packed-refs
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
def _read_small_regular(path: Path, limit: int = 512) -> str | None:
|
|
1114
|
+
"""Read a control file only if it is a regular file (never follow a
|
|
1115
|
+
symlink or block on a FIFO); None when absent. A file larger than
|
|
1116
|
+
`limit` is refused rather than truncated: a truncated read would silently
|
|
1117
|
+
drop entries (a HEAD ref past the cut would read as unborn)."""
|
|
1118
|
+
try:
|
|
1119
|
+
st = os.lstat(path)
|
|
1120
|
+
except OSError:
|
|
1121
|
+
return None
|
|
1122
|
+
if not stat.S_ISREG(st.st_mode):
|
|
1123
|
+
raise _altered(f"{path.name} is not a regular file")
|
|
1124
|
+
if st.st_size > limit:
|
|
1125
|
+
raise _altered(f"{path.name} is oversized ({st.st_size} bytes)")
|
|
1126
|
+
try:
|
|
1127
|
+
with open(path, "rb") as fh:
|
|
1128
|
+
return fh.read(limit).decode("utf-8", errors="replace")
|
|
1129
|
+
except OSError as exc: # chmod 000 and friends: unreadable is altered, not "absent"
|
|
1130
|
+
raise _altered(f"{path.name} is unreadable ({exc.strerror})") from None
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def _ref_sha(git_dir: Path, ref: str, _depth: int = 0) -> str | None:
|
|
1134
|
+
"""A ref's sha from its loose file (inside .git only) or packed-refs,
|
|
1135
|
+
following a symbolic loose ref (`ref: refs/...`) the way git resolves it.
|
|
1136
|
+
Without the chain, a HEAD -> refs/heads/X -> refs/heads/real symref left the
|
|
1137
|
+
object unverified (the non-sha loose file read as unborn), so an emptied
|
|
1138
|
+
object store behind `real` slipped past the guard and only failed later at a
|
|
1139
|
+
raw `git reset` ("Could not parse object 'HEAD'"). A too-deep chain is a
|
|
1140
|
+
cycle git never writes: tampering, not a state to resolve."""
|
|
1141
|
+
if _depth > 8:
|
|
1142
|
+
raise _altered("a ref symref chain is too deep (a cycle?)")
|
|
1143
|
+
loose = _read_small_regular(git_dir / ref)
|
|
1144
|
+
if loose is not None:
|
|
1145
|
+
value = loose.strip()
|
|
1146
|
+
if _SHA_RE.fullmatch(value):
|
|
1147
|
+
return value
|
|
1148
|
+
if value.startswith("ref: "):
|
|
1149
|
+
target = value[5:].strip()
|
|
1150
|
+
if not _REF_RE.fullmatch(target) or ".." in target.split("/"):
|
|
1151
|
+
raise _altered("a ref names a target outside refs/")
|
|
1152
|
+
return _ref_sha(git_dir, target, _depth + 1)
|
|
1153
|
+
return None # neither a sha nor a symref: an unborn/placeholder ref
|
|
1154
|
+
packed = _read_small_regular(git_dir / "packed-refs", limit=PACKED_REFS_MAX_BYTES) or ""
|
|
1155
|
+
for line in packed.splitlines():
|
|
1156
|
+
if line.endswith(" " + ref) and _SHA_RE.fullmatch(line.split(" ", 1)[0]):
|
|
1157
|
+
return line.split(" ", 1)[0]
|
|
1158
|
+
return None
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def _head_commit_sha(git_dir: Path) -> str | None:
|
|
1162
|
+
"""The sha HEAD names, or None when HEAD's branch is unborn. A HEAD that
|
|
1163
|
+
is not a plain sha or a well-formed `ref: refs/...` (a path with `..`, an
|
|
1164
|
+
absolute path, junk) is tampering, not a state git ever writes."""
|
|
1165
|
+
head = (_read_small_regular(git_dir / "HEAD") or "").strip()
|
|
1166
|
+
if not head:
|
|
1167
|
+
raise _altered("HEAD is empty")
|
|
1168
|
+
if _SHA_RE.fullmatch(head):
|
|
1169
|
+
return head
|
|
1170
|
+
if not head.startswith("ref: "):
|
|
1171
|
+
raise _altered("HEAD is malformed")
|
|
1172
|
+
ref = head[5:].strip()
|
|
1173
|
+
if not _REF_RE.fullmatch(ref) or ".." in ref.split("/"):
|
|
1174
|
+
raise _altered("HEAD names a ref outside refs/")
|
|
1175
|
+
return _ref_sha(git_dir, ref)
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def _any_ref_sha(git_dir: Path) -> str | None:
|
|
1179
|
+
"""Some commit-bearing ref's sha (packed first, then loose under refs/),
|
|
1180
|
+
used to verify the object store when HEAD is unborn; None when the
|
|
1181
|
+
repository has no refs at all."""
|
|
1182
|
+
packed = _read_small_regular(git_dir / "packed-refs", limit=PACKED_REFS_MAX_BYTES) or ""
|
|
1183
|
+
for line in packed.splitlines():
|
|
1184
|
+
if line and not line.startswith(("#", "^")):
|
|
1185
|
+
sha = line.split(" ", 1)[0]
|
|
1186
|
+
if _SHA_RE.fullmatch(sha):
|
|
1187
|
+
return sha
|
|
1188
|
+
top = git_dir / "refs"
|
|
1189
|
+
if top.is_dir():
|
|
1190
|
+
for path in sorted(top.rglob("*")):
|
|
1191
|
+
if path.is_file() and not path.is_symlink():
|
|
1192
|
+
value = (_read_small_regular(path) or "").strip()
|
|
1193
|
+
if _SHA_RE.fullmatch(value):
|
|
1194
|
+
return value
|
|
1195
|
+
return None
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def _ensure_regular_config(root: Path | None) -> None:
|
|
1199
|
+
"""Sanitize .git/config before any git command reads it. A session can
|
|
1200
|
+
(a) replace the file with a FIFO/symlink/device — git's own parse, or a
|
|
1201
|
+
read, would BLOCK forever with no writer, hanging the wake — or (b) leave
|
|
1202
|
+
a regular file that INCLUDES a FIFO or rewrites URLs. Non-regular files are
|
|
1203
|
+
replaced with a minimal config; a regular file's redirect sections
|
|
1204
|
+
(url/include/includeIf) are stripped in place. A clean config is left
|
|
1205
|
+
untouched (a cheap lstat and, at most, one small read)."""
|
|
1206
|
+
if root is None:
|
|
1207
|
+
return
|
|
1208
|
+
# every session-writable config file git reads under credential: the
|
|
1209
|
+
# main local config and the per-worktree config (extensions.worktreeConfig).
|
|
1210
|
+
# global/system are /dev/null via the env, so this list is exhaustive.
|
|
1211
|
+
for cfg in (root / ".git" / "config", root / ".git" / "config.worktree"):
|
|
1212
|
+
_sanitize_one_config(cfg)
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
def _sanitize_one_config(cfg: Path) -> None:
|
|
1216
|
+
try:
|
|
1217
|
+
st = os.lstat(cfg)
|
|
1218
|
+
except OSError:
|
|
1219
|
+
return
|
|
1220
|
+
|
|
1221
|
+
def _write(data: bytes) -> None:
|
|
1222
|
+
with contextlib.suppress(OSError):
|
|
1223
|
+
if cfg.exists():
|
|
1224
|
+
cfg.unlink()
|
|
1225
|
+
fd = os.open(cfg, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
|
|
1226
|
+
try:
|
|
1227
|
+
os.write(fd, data)
|
|
1228
|
+
finally:
|
|
1229
|
+
os.close(fd)
|
|
1230
|
+
|
|
1231
|
+
if not stat.S_ISREG(st.st_mode) or st.st_size > 1_000_000:
|
|
1232
|
+
_write(_MINIMAL_GIT_CONFIG) # FIFO/symlink/device/oversize: hostile
|
|
1233
|
+
return
|
|
1234
|
+
# a regular file never blocks a read (reading does NOT follow includes),
|
|
1235
|
+
# so this is safe; only the redirect sections are removed
|
|
1236
|
+
try:
|
|
1237
|
+
text = cfg.read_text(errors="replace")
|
|
1238
|
+
except OSError:
|
|
1239
|
+
return
|
|
1240
|
+
kept: list[str] = []
|
|
1241
|
+
dropping = False
|
|
1242
|
+
changed = False
|
|
1243
|
+
for line in text.splitlines(keepends=True):
|
|
1244
|
+
m = _SECTION_RE.match(line)
|
|
1245
|
+
if m is not None: # a new section header decides the next block
|
|
1246
|
+
dropping = m.group(1).split(".", 1)[0].casefold() in _REDIRECT_SECTIONS
|
|
1247
|
+
if dropping:
|
|
1248
|
+
changed = True
|
|
1249
|
+
continue
|
|
1250
|
+
kept.append(line)
|
|
1251
|
+
if changed:
|
|
1252
|
+
_write("".join(kept).encode())
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
def _filter_override_pairs(root: Path | None) -> list[tuple[str, str]]:
|
|
1256
|
+
"""GIT_CONFIG pairs that neutralize every filter driver the REPO config
|
|
1257
|
+
defines (each overridden to a passthrough) plus attribute files. The
|
|
1258
|
+
workspace's .git/config and .gitattributes are session-written, so a
|
|
1259
|
+
checkout there must never execute a configured smudge/clean/process
|
|
1260
|
+
command with the orchestrator's permissions — the same neutralization the
|
|
1261
|
+
dispatched job script applies. Env-var pairs, not `-c`: a driver name
|
|
1262
|
+
containing '=' or dots survives correctly."""
|
|
1263
|
+
pairs: list[tuple[str, str]] = [("core.attributesFile", "/dev/null")]
|
|
1264
|
+
if root is None:
|
|
1265
|
+
return pairs
|
|
1266
|
+
listing = subprocess.run(
|
|
1267
|
+
[
|
|
1268
|
+
"git",
|
|
1269
|
+
"-C",
|
|
1270
|
+
str(root),
|
|
1271
|
+
"config",
|
|
1272
|
+
"-z",
|
|
1273
|
+
"--get-regexp",
|
|
1274
|
+
r"^filter\..*\.(clean|smudge|process)$",
|
|
1275
|
+
],
|
|
1276
|
+
env={
|
|
1277
|
+
**os.environ,
|
|
1278
|
+
"GIT_CONFIG_GLOBAL": "/dev/null",
|
|
1279
|
+
"GIT_CONFIG_SYSTEM": "/dev/null",
|
|
1280
|
+
"GIT_CONFIG_COUNT": "0",
|
|
1281
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
1282
|
+
},
|
|
1283
|
+
capture_output=True,
|
|
1284
|
+
timeout=30,
|
|
1285
|
+
)
|
|
1286
|
+
# BYTES + surrogateescape, never text=True: the config is session-written,
|
|
1287
|
+
# so a non-UTF-8 byte in a value must not crash the git call — and the
|
|
1288
|
+
# surrogates roundtrip through the env (os.fsencode), so an override key
|
|
1289
|
+
# still matches a weird-byte driver name EXACTLY (a lossy decode would
|
|
1290
|
+
# silently fail to neutralize that driver).
|
|
1291
|
+
# -z: NUL-separated records, each "key\nvalue" — a value containing a
|
|
1292
|
+
# newline can never masquerade as a second record.
|
|
1293
|
+
for record in listing.stdout.decode("utf-8", "surrogateescape").split("\0"):
|
|
1294
|
+
key = record.split("\n", 1)[0]
|
|
1295
|
+
if not key.startswith("filter.") or "." not in key[len("filter.") :]:
|
|
1296
|
+
continue
|
|
1297
|
+
driver = key[len("filter.") : key.rindex(".")]
|
|
1298
|
+
pairs += [
|
|
1299
|
+
(f"filter.{driver}.clean", "cat"),
|
|
1300
|
+
(f"filter.{driver}.smudge", "cat"),
|
|
1301
|
+
(f"filter.{driver}.process", ""),
|
|
1302
|
+
]
|
|
1303
|
+
return pairs
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def _git_env(token: str | None, root: Path | None = None) -> dict[str, str]:
|
|
1307
|
+
"""Environment for a git invocation: host global/system config never
|
|
1308
|
+
loads (a host-configured filter driver must not be selectable by a
|
|
1309
|
+
session-written .gitattributes), repo-defined filter drivers are
|
|
1310
|
+
overridden to passthrough, and the token — present only for network
|
|
1311
|
+
subcommands — rides an env-injected header."""
|
|
1312
|
+
env = dict(os.environ)
|
|
1313
|
+
env["GIT_TERMINAL_PROMPT"] = "0"
|
|
1314
|
+
env["GIT_CONFIG_GLOBAL"] = "/dev/null"
|
|
1315
|
+
env["GIT_CONFIG_SYSTEM"] = "/dev/null"
|
|
1316
|
+
# The session owns the clone and can write refs/replace/*, which git
|
|
1317
|
+
# honors in ancestry checks (merge-base) and object lookups
|
|
1318
|
+
# (rev-parse sha:path) — either would let a doctored object stand in
|
|
1319
|
+
# for a real one during post-session verification. The kernel never
|
|
1320
|
+
# uses replace refs; disable them on every invocation.
|
|
1321
|
+
env["GIT_NO_REPLACE_OBJECTS"] = "1"
|
|
1322
|
+
pairs = _filter_override_pairs(root)
|
|
1323
|
+
if token is not None:
|
|
1324
|
+
basic = base64.b64encode(f"x-access-token:{token}".encode()).decode()
|
|
1325
|
+
pairs.append(("http.https://github.com/.extraheader", f"Authorization: Basic {basic}"))
|
|
1326
|
+
env["GIT_CONFIG_COUNT"] = str(len(pairs))
|
|
1327
|
+
for i, (k, v) in enumerate(pairs):
|
|
1328
|
+
env[f"GIT_CONFIG_KEY_{i}"] = k
|
|
1329
|
+
env[f"GIT_CONFIG_VALUE_{i}"] = v
|
|
1330
|
+
return env
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
def _run_git(args: list[str], env: dict[str, str], timeout: float | None = None) -> str:
|
|
1334
|
+
try:
|
|
1335
|
+
result = subprocess.run(
|
|
1336
|
+
args, capture_output=True, text=True, env=env, check=False, timeout=timeout
|
|
1337
|
+
)
|
|
1338
|
+
except subprocess.TimeoutExpired as exc:
|
|
1339
|
+
raise GitError(f"git timed out after {timeout:.0f}s: {' '.join(args[3:5])}") from exc
|
|
1340
|
+
if result.returncode != 0:
|
|
1341
|
+
detail = (result.stderr or result.stdout or "").strip()
|
|
1342
|
+
skip = {"-C", "-c"}
|
|
1343
|
+
subcommand = next(
|
|
1344
|
+
(a for i, a in enumerate(args[1:], 1) if a not in skip and args[i - 1] not in skip),
|
|
1345
|
+
"?",
|
|
1346
|
+
)
|
|
1347
|
+
raise GitError(f"git {subcommand} failed: {detail}")
|
|
1348
|
+
return result.stdout.strip()
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
@dataclass
|
|
1352
|
+
class Workspace:
|
|
1353
|
+
"""A working clone the agent session edits; pushes happen orchestrator-side."""
|
|
1354
|
+
|
|
1355
|
+
root: Path
|
|
1356
|
+
auth: TokenProvider | None = None
|
|
1357
|
+
dry_run: bool = False
|
|
1358
|
+
url: str | None = None
|
|
1359
|
+
|
|
1360
|
+
def git(self, *args: str) -> str:
|
|
1361
|
+
"""Run a local git subcommand: no credential, no child-spawning config,
|
|
1362
|
+
repo-defined filter drivers neutralized. A session-reshaped .git is
|
|
1363
|
+
refused before git runs (ensure_regular_git_dir, which also
|
|
1364
|
+
sanitizes the config first)."""
|
|
1365
|
+
ensure_regular_git_dir(self.root)
|
|
1366
|
+
return _run_git(
|
|
1367
|
+
["git", "-C", str(self.root), *SAFE_GIT_FLAGS, *args], _git_env(None, self.root)
|
|
1368
|
+
)
|
|
1369
|
+
|
|
1370
|
+
def git_network(self, *args: str) -> str:
|
|
1371
|
+
"""Run a git subcommand that talks to the remote, with credentials.
|
|
1372
|
+
The config is sanitized first (redirect sections stripped), and the
|
|
1373
|
+
fetch/push pass an explicit URL + refspec, so no session-controlled
|
|
1374
|
+
remote or rewrite can steer a credentialed op."""
|
|
1375
|
+
if args and args[0] not in NETWORK_GIT_COMMANDS:
|
|
1376
|
+
raise ValueError(f"{args[0]!r} is not a network git command")
|
|
1377
|
+
ensure_regular_git_dir(self.root) # sanitizes the config first, too
|
|
1378
|
+
token = self.auth.token() if self.auth is not None else None
|
|
1379
|
+
return _run_git(
|
|
1380
|
+
["git", "-C", str(self.root), *SAFE_GIT_FLAGS, *args],
|
|
1381
|
+
_git_env(token, self.root),
|
|
1382
|
+
timeout=NETWORK_GIT_TIMEOUT_S,
|
|
1383
|
+
)
|
|
1384
|
+
|
|
1385
|
+
def remote_url(self) -> str:
|
|
1386
|
+
"""The remote URL as recorded at clone time, read token-free."""
|
|
1387
|
+
return self.git("config", "--get", "remote.origin.url")
|
|
1388
|
+
|
|
1389
|
+
@classmethod
|
|
1390
|
+
def clone(
|
|
1391
|
+
cls,
|
|
1392
|
+
url: str,
|
|
1393
|
+
dest: Path,
|
|
1394
|
+
auth: TokenProvider | None = None,
|
|
1395
|
+
dry_run: bool = False,
|
|
1396
|
+
) -> Workspace:
|
|
1397
|
+
token = auth.token() if auth is not None else None
|
|
1398
|
+
_run_git(["git", "clone", "--quiet", *SAFE_GIT_FLAGS, url, str(dest)], _git_env(token))
|
|
1399
|
+
return cls(root=dest, auth=auth, dry_run=dry_run, url=url)
|
|
1400
|
+
|
|
1401
|
+
def branch(self, name: str) -> None:
|
|
1402
|
+
self.git("switch", "-c", name)
|
|
1403
|
+
|
|
1404
|
+
def staged_paths(self) -> list[str]:
|
|
1405
|
+
"""Staged paths, NUL-delimited so unicode/space/newline names survive."""
|
|
1406
|
+
output = self.git("diff", "--cached", "--name-only", "-z")
|
|
1407
|
+
return [entry for entry in output.split("\0") if entry]
|
|
1408
|
+
|
|
1409
|
+
def commit_all(
|
|
1410
|
+
self,
|
|
1411
|
+
message: str,
|
|
1412
|
+
author: str,
|
|
1413
|
+
forbidden: Callable[[str], bool] | None = None,
|
|
1414
|
+
) -> None:
|
|
1415
|
+
"""Stage everything and commit.
|
|
1416
|
+
|
|
1417
|
+
`forbidden(path)` — normally `partial(path_is_forbidden, contract=...)`
|
|
1418
|
+
— vetoes the commit if the session touched a path the contract puts
|
|
1419
|
+
off-limits, so the invariant is enforced against the diff, not only
|
|
1420
|
+
against the contract's own scope list.
|
|
1421
|
+
"""
|
|
1422
|
+
self.git("add", "-A")
|
|
1423
|
+
staged = self.staged_paths()
|
|
1424
|
+
if not staged:
|
|
1425
|
+
raise NothingToCommit("nothing to commit; working tree clean")
|
|
1426
|
+
if forbidden is not None:
|
|
1427
|
+
violations = [p for p in staged if forbidden(p)]
|
|
1428
|
+
if violations:
|
|
1429
|
+
self.git("reset")
|
|
1430
|
+
raise ForbiddenPathError(f"commit touches forbidden paths: {sorted(violations)}")
|
|
1431
|
+
self.git(
|
|
1432
|
+
"-c",
|
|
1433
|
+
f"user.name={author}",
|
|
1434
|
+
"-c",
|
|
1435
|
+
f"user.email={author}@users.noreply.github.com",
|
|
1436
|
+
"commit",
|
|
1437
|
+
"-m",
|
|
1438
|
+
message,
|
|
1439
|
+
)
|
|
1440
|
+
|
|
1441
|
+
def fetch_origin(self) -> None:
|
|
1442
|
+
"""Refresh refs/remotes/origin/* from the URL captured at clone time —
|
|
1443
|
+
never the "origin" remote, whose url and uploadpack live in
|
|
1444
|
+
session-writable .git/config. The credential is host-scoped
|
|
1445
|
+
(extraheader), so a rewritten URL could not receive it, but the
|
|
1446
|
+
CONTENT must come from the canonical repo too: a poisoned fetch
|
|
1447
|
+
source would forge what origin/<base> means to every downstream
|
|
1448
|
+
comparison."""
|
|
1449
|
+
if self.dry_run:
|
|
1450
|
+
log.info("[dry-run] fetch into %s", self.root)
|
|
1451
|
+
return
|
|
1452
|
+
target = self.url or self.remote_url()
|
|
1453
|
+
self.git_network("fetch", "--prune", target, "--", "+refs/heads/*:refs/remotes/origin/*")
|
|
1454
|
+
|
|
1455
|
+
def fetch_branch(self, branch: str) -> None:
|
|
1456
|
+
"""Fetch one branch into FETCH_HEAD from the canonical URL (resolved
|
|
1457
|
+
before the clean-config window, never the mutable "origin" remote)."""
|
|
1458
|
+
target = self.url or self.remote_url()
|
|
1459
|
+
self.git_network("fetch", target, branch)
|
|
1460
|
+
|
|
1461
|
+
def push(self, branch: str) -> None:
|
|
1462
|
+
if self.dry_run:
|
|
1463
|
+
log.info("[dry-run] push %s from %s", branch, self.root)
|
|
1464
|
+
return
|
|
1465
|
+
# Push to the URL captured at clone time: the session can rewrite
|
|
1466
|
+
# remote.origin.url, and "origin" would follow it.
|
|
1467
|
+
target = self.url or self.remote_url()
|
|
1468
|
+
self.git_network("push", target, "--", f"{branch}:{branch}")
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
def contract_at(ws: Any, sha: str) -> str:
|
|
1472
|
+
"""The target's contract text at `sha` — `.outerloop.yaml`, else the legacy
|
|
1473
|
+
`.autoresearch.yaml`. `ws` is a Workspace (anything with `.git(*args)`); a
|
|
1474
|
+
missing path is a git failure, which is the fallback signal. Raises GitError
|
|
1475
|
+
when the commit has neither, naming both candidates."""
|
|
1476
|
+
|
|
1477
|
+
def read(name: str) -> str | None:
|
|
1478
|
+
try:
|
|
1479
|
+
return ws.git("show", f"{sha}:{name}")
|
|
1480
|
+
except GitError:
|
|
1481
|
+
return None
|
|
1482
|
+
|
|
1483
|
+
found = find_contract(read)
|
|
1484
|
+
if found is None:
|
|
1485
|
+
raise GitError(f"no contract at {sha} ({' or '.join(CONTRACT_NAMES)})")
|
|
1486
|
+
return found[1]
|