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
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Agent-session reviewer: runs the reviewer as an agent over a PR-head
|
|
2
|
+
checkout, recording its verdict through the installed syscall tool.
|
|
3
|
+
|
|
4
|
+
`run_agent_review` is the orchestration core, testable with a fake harness and
|
|
5
|
+
client. It builds on the shared vocabulary in `review`: the same skip rules,
|
|
6
|
+
the same rubric (via `build_agent_brief`), the same result policy
|
|
7
|
+
(`review_result_from_role`), and the same inline-posting path (`format_review`
|
|
8
|
+
-> `post_round_review`).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from outerloop.github import GitHubClient
|
|
21
|
+
from outerloop.harness import (
|
|
22
|
+
Harness,
|
|
23
|
+
backend_id,
|
|
24
|
+
budget_exhausted,
|
|
25
|
+
outage,
|
|
26
|
+
)
|
|
27
|
+
from outerloop.posting import (
|
|
28
|
+
EXPECTED_FAILURES,
|
|
29
|
+
post_round_review,
|
|
30
|
+
post_skip_stub,
|
|
31
|
+
)
|
|
32
|
+
from outerloop.review import (
|
|
33
|
+
MARKER,
|
|
34
|
+
PullRequest,
|
|
35
|
+
build_agent_brief,
|
|
36
|
+
format_comment,
|
|
37
|
+
format_review,
|
|
38
|
+
skip_reason,
|
|
39
|
+
)
|
|
40
|
+
from outerloop.role_runner import run_role
|
|
41
|
+
from outerloop.roles import review_result_from_role, reviewer_spec
|
|
42
|
+
from outerloop.rolespec import RoleSpec
|
|
43
|
+
|
|
44
|
+
log = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
# Files an agent harness may auto-load as INSTRUCTIONS from a checkout. In an
|
|
47
|
+
# untrusted tree they are attack surface (a PR-authored CLAUDE.md, or
|
|
48
|
+
# .claude/settings.json hooks that execute commands), so the CLIs rename them
|
|
49
|
+
# before any session starts. Renamed — not deleted — so a judge can still read
|
|
50
|
+
# them as data. Backend-agnostic defense in depth behind claude's --bare.
|
|
51
|
+
INSTRUCTION_FILES = ("CLAUDE.md", "AGENTS.md", ".claude", ".mcp.json")
|
|
52
|
+
SANITIZED_SUFFIX = ".pr-data"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def sanitize_checkout(tree: Path) -> tuple[int, int]:
|
|
56
|
+
"""Rename instruction-bearing files/dirs anywhere under `tree` so no agent
|
|
57
|
+
backend auto-loads untrusted content as instructions. Returns
|
|
58
|
+
(renamed, failed). A non-zero `failed` means an instruction file is still
|
|
59
|
+
live (e.g. a crafted name collision blocking the rename) — callers must
|
|
60
|
+
FAIL CLOSED and skip the session rather than judge an unsanitized tree.
|
|
61
|
+
Never raises."""
|
|
62
|
+
renamed = failed = 0
|
|
63
|
+
if not tree.is_dir():
|
|
64
|
+
return 0, 0
|
|
65
|
+
# bottom-up so a renamed directory doesn't orphan paths found beneath it
|
|
66
|
+
for path in sorted(tree.rglob("*"), key=lambda p: len(p.parts), reverse=True):
|
|
67
|
+
if path.name in INSTRUCTION_FILES:
|
|
68
|
+
try:
|
|
69
|
+
path.rename(path.with_name(path.name + SANITIZED_SUFFIX))
|
|
70
|
+
renamed += 1
|
|
71
|
+
except OSError as exc:
|
|
72
|
+
log.warning("could not sanitize %s: %s", path, exc)
|
|
73
|
+
failed += 1
|
|
74
|
+
return renamed, failed
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _pull_request(client: GitHubClient, repo: str, number: int) -> tuple[PullRequest, dict]:
|
|
78
|
+
pr_data = client.get_pull_request(repo, number)
|
|
79
|
+
diff = client.get_pull_request_diff(repo, number)
|
|
80
|
+
pr = PullRequest(
|
|
81
|
+
repo=repo,
|
|
82
|
+
number=number,
|
|
83
|
+
title=str(pr_data.get("title", "")),
|
|
84
|
+
body=str(pr_data.get("body") or ""),
|
|
85
|
+
diff=diff,
|
|
86
|
+
author=str((pr_data.get("user") or {}).get("login", "")),
|
|
87
|
+
# `or []`: the labels field may be null in the payload, not just absent
|
|
88
|
+
labels=tuple(
|
|
89
|
+
str(label.get("name", ""))
|
|
90
|
+
for label in (pr_data.get("labels") or [])
|
|
91
|
+
if isinstance(label, dict)
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
return pr, pr_data
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _emit(
|
|
98
|
+
path: Path,
|
|
99
|
+
repo: str,
|
|
100
|
+
number: int,
|
|
101
|
+
*,
|
|
102
|
+
kind: str,
|
|
103
|
+
data: dict[str, Any] | None = None,
|
|
104
|
+
detail: str = "",
|
|
105
|
+
reviewed_by: str = "",
|
|
106
|
+
lens: str = "",
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Write the posting step's input. repo/number ride along so the poster
|
|
109
|
+
can refuse an envelope that does not match its own PR reference;
|
|
110
|
+
reviewed_by (backend/model) rides along for the round stamp."""
|
|
111
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
path.write_text(
|
|
113
|
+
json.dumps(
|
|
114
|
+
{
|
|
115
|
+
"repo": repo,
|
|
116
|
+
"number": number,
|
|
117
|
+
"kind": kind,
|
|
118
|
+
"data": data,
|
|
119
|
+
"detail": detail,
|
|
120
|
+
"reviewed_by": reviewed_by,
|
|
121
|
+
"lens": lens,
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def run_agent_review(
|
|
128
|
+
client: GitHubClient,
|
|
129
|
+
repo: str,
|
|
130
|
+
number: int,
|
|
131
|
+
harness: Harness,
|
|
132
|
+
workspace: Path,
|
|
133
|
+
*,
|
|
134
|
+
bot_login: str,
|
|
135
|
+
spec: RoleSpec | None = None,
|
|
136
|
+
today: str | None = None,
|
|
137
|
+
emit_path: Path | None = None,
|
|
138
|
+
lens: str = "",
|
|
139
|
+
) -> str | None:
|
|
140
|
+
"""Review PR #`number` as an agent session over `workspace` (a
|
|
141
|
+
PR-head checkout the caller prepared). Post the findings inline via the
|
|
142
|
+
Reviews API. Returns the round label, or None when it skipped or could not
|
|
143
|
+
produce a verdict. Advisory: never raises the expected failures, so it can
|
|
144
|
+
never turn a target repo's CI red.
|
|
145
|
+
|
|
146
|
+
With `emit_path`, nothing is posted: the raw findings (or a skip
|
|
147
|
+
envelope) are written there for a separate posting step — the least-token
|
|
148
|
+
split (docs/design/reviewer-infra.md). The session job runs with read-only
|
|
149
|
+
permissions; the posting job holds the write token but runs no session.
|
|
150
|
+
EVERY outcome writes an envelope — findings, a PR-visible skip-stub
|
|
151
|
+
(missing key, errored session), or skip-clean (bot PR, opt-out; posts
|
|
152
|
+
nothing) — so the posting job can treat a missing artifact as a broken
|
|
153
|
+
session, loudly.
|
|
154
|
+
"""
|
|
155
|
+
spec = spec or reviewer_spec()
|
|
156
|
+
today = today or datetime.now(UTC).date().isoformat()
|
|
157
|
+
try:
|
|
158
|
+
pr, pr_data = _pull_request(client, repo, number)
|
|
159
|
+
skip = skip_reason(pr, bot_login)
|
|
160
|
+
if skip is not None:
|
|
161
|
+
log.info("skipping agent review of %s#%s: %s", repo, number, skip)
|
|
162
|
+
if emit_path is not None:
|
|
163
|
+
# even a clean skip leaves an envelope: the posting job can
|
|
164
|
+
# then REQUIRE an artifact, so "no artifact" always means a
|
|
165
|
+
# broken session, never an ambiguous quiet day
|
|
166
|
+
_emit(emit_path, repo, number, kind="skip-clean", detail=skip)
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
from outerloop.syscall import tool_command
|
|
170
|
+
|
|
171
|
+
brief = build_agent_brief(pr, today, syscall_cmd=tool_command(workspace), lens=lens)
|
|
172
|
+
role_result = run_role(spec, harness, brief, workspace)
|
|
173
|
+
review = review_result_from_role(role_result)
|
|
174
|
+
if review is None:
|
|
175
|
+
# No verdict: an errored or refused session, not a clean read. An
|
|
176
|
+
# API outage or a budget-exhausted session (walltime/turns) says so
|
|
177
|
+
# on the thread; other failures are logged, advisory-silent.
|
|
178
|
+
detail = role_result.error or role_result.session.stop_reason
|
|
179
|
+
log.warning("agent review produced no verdict on %s#%s: %s", repo, number, detail)
|
|
180
|
+
# `detail` is already api-key-redacted by the harness (it owns
|
|
181
|
+
# its own secret), so no secrets are passed here.
|
|
182
|
+
if emit_path is not None:
|
|
183
|
+
# EVERY errored session surfaces on the PR in the split
|
|
184
|
+
# topology: this job's log is not the record — the stub the
|
|
185
|
+
# post job publishes is.
|
|
186
|
+
_emit(
|
|
187
|
+
emit_path,
|
|
188
|
+
repo,
|
|
189
|
+
number,
|
|
190
|
+
kind="skip-stub",
|
|
191
|
+
detail=detail,
|
|
192
|
+
reviewed_by=backend_id(harness),
|
|
193
|
+
)
|
|
194
|
+
elif outage(role_result.session) or budget_exhausted(role_result.session):
|
|
195
|
+
post_skip_stub(client, repo, number, "advisory review", RuntimeError(detail))
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
if emit_path is not None:
|
|
199
|
+
# raw data, not rendered text: the posting step re-validates and
|
|
200
|
+
# sanitizes at the render boundary, so the artifact crossing the
|
|
201
|
+
# job boundary carries no pre-trusted markup
|
|
202
|
+
_emit(
|
|
203
|
+
emit_path,
|
|
204
|
+
repo,
|
|
205
|
+
number,
|
|
206
|
+
kind="findings",
|
|
207
|
+
data=role_result.data,
|
|
208
|
+
reviewed_by=backend_id(harness),
|
|
209
|
+
lens=lens,
|
|
210
|
+
)
|
|
211
|
+
cost = role_result.session.cost_usd
|
|
212
|
+
log.info(
|
|
213
|
+
"emitted findings for %s#%s (cost=%s turns=%d)",
|
|
214
|
+
repo,
|
|
215
|
+
number,
|
|
216
|
+
f"${cost:.2f}" if cost else "unreported",
|
|
217
|
+
role_result.session.num_turns,
|
|
218
|
+
)
|
|
219
|
+
return "emitted"
|
|
220
|
+
|
|
221
|
+
rendered = format_review(review, pr.diff)
|
|
222
|
+
full = format_comment(review)
|
|
223
|
+
if rendered is None or full is None:
|
|
224
|
+
log.info("nothing to post")
|
|
225
|
+
return None
|
|
226
|
+
body, inline = rendered
|
|
227
|
+
round_label = post_round_review(
|
|
228
|
+
client,
|
|
229
|
+
repo,
|
|
230
|
+
number,
|
|
231
|
+
MARKER,
|
|
232
|
+
body,
|
|
233
|
+
inline,
|
|
234
|
+
pr_data,
|
|
235
|
+
fallback_body=full,
|
|
236
|
+
reviewed_by=backend_id(harness),
|
|
237
|
+
)
|
|
238
|
+
cost = role_result.session.cost_usd
|
|
239
|
+
log.info(
|
|
240
|
+
"posted agent review (%s) on %s#%s (cost=%s turns=%d)",
|
|
241
|
+
round_label,
|
|
242
|
+
repo,
|
|
243
|
+
number,
|
|
244
|
+
f"${cost:.2f}" if cost else "unreported",
|
|
245
|
+
role_result.session.num_turns,
|
|
246
|
+
)
|
|
247
|
+
return round_label
|
|
248
|
+
except EXPECTED_FAILURES as exc: # advisory: never fail the target repo's CI
|
|
249
|
+
log.warning("agent review did not complete: %s: %s", type(exc).__name__, exc)
|
|
250
|
+
if emit_path is not None:
|
|
251
|
+
# the invariant holds here too: the workflow backstop would cover
|
|
252
|
+
# a missing file, but with a generic detail — the real failure is
|
|
253
|
+
# the one worth reading on the PR
|
|
254
|
+
with contextlib.suppress(Exception):
|
|
255
|
+
_emit(
|
|
256
|
+
emit_path,
|
|
257
|
+
repo,
|
|
258
|
+
number,
|
|
259
|
+
kind="skip-stub",
|
|
260
|
+
detail=f"{type(exc).__name__}: {exc}",
|
|
261
|
+
reviewed_by=backend_id(harness),
|
|
262
|
+
)
|
|
263
|
+
return None
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Entry point for the agent-session advisory reviewer.
|
|
2
|
+
|
|
3
|
+
Runs the reviewer as an agent over a PR-head checkout the workflow prepared
|
|
4
|
+
(REVIEW_CHECKOUT), and posts the findings inline. Exits 0 even on skip
|
|
5
|
+
or failure — an advisory reviewer must never turn a target repo's CI red.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from outerloop.github import EnvTokenProvider, GitHubClient
|
|
16
|
+
from outerloop.harness import Harness
|
|
17
|
+
from outerloop.review_agent import (
|
|
18
|
+
_emit,
|
|
19
|
+
run_agent_review,
|
|
20
|
+
sanitize_checkout,
|
|
21
|
+
)
|
|
22
|
+
from outerloop.role_runner import build_harness
|
|
23
|
+
from outerloop.roles import reviewer_spec
|
|
24
|
+
from outerloop.rolespec import RoleSpec
|
|
25
|
+
|
|
26
|
+
log = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _skip_stub(emit_env: str, repo: str, number: int, detail: str, reviewed_by: str) -> None:
|
|
30
|
+
"""A fail-closed skip still leaves an envelope when emitting: the post job
|
|
31
|
+
REQUIRES an artifact, and a standing reviewer that cannot run must say so
|
|
32
|
+
on the PR rather than fail into silence."""
|
|
33
|
+
log.warning("%s; skipping review", detail)
|
|
34
|
+
if emit_env:
|
|
35
|
+
_emit(
|
|
36
|
+
Path(emit_env).resolve(),
|
|
37
|
+
repo,
|
|
38
|
+
number,
|
|
39
|
+
kind="skip-stub",
|
|
40
|
+
detail=detail,
|
|
41
|
+
reviewed_by=reviewed_by,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_reviewer_harness(spec: RoleSpec) -> tuple[Harness | None, str, str]:
|
|
46
|
+
"""Resolve the reviewer-env backend contract into a built harness:
|
|
47
|
+
`(harness, "", backend_label)` on success, `(None, why, backend_label)`
|
|
48
|
+
on a config that must skip. The ONE owner of the REVIEW_BACKEND /
|
|
49
|
+
REVIEW_MODEL / REVIEW_HERMES_* / key-var contract, shared by the
|
|
50
|
+
reviewer and the summarizer CLIs — free-form caller inputs are compared
|
|
51
|
+
with EXACTLY GitHub's expression semantics (case-insensitive, never
|
|
52
|
+
trimmed) so this code and the workflow's key-injection expressions can
|
|
53
|
+
never disagree about a value."""
|
|
54
|
+
backend = os.environ.get("REVIEW_BACKEND", "claude").lower()
|
|
55
|
+
review_model = os.environ.get("REVIEW_MODEL", "").strip()
|
|
56
|
+
hermes_provider = os.environ.get("REVIEW_HERMES_PROVIDER", "").lower() or "openrouter"
|
|
57
|
+
key_var = {
|
|
58
|
+
"claude": "ANTHROPIC_REVIEWER_KEY",
|
|
59
|
+
"codex": "OPENAI_REVIEWER_KEY",
|
|
60
|
+
"hermes": {"openrouter": "OPENROUTER_API_KEY", "openai": "OPENAI_REVIEWER_KEY"}.get(
|
|
61
|
+
hermes_provider
|
|
62
|
+
),
|
|
63
|
+
}.get(backend)
|
|
64
|
+
if key_var is None:
|
|
65
|
+
what = (
|
|
66
|
+
f"unknown REVIEW_HERMES_PROVIDER {hermes_provider!r}"
|
|
67
|
+
if backend == "hermes"
|
|
68
|
+
else f"unknown REVIEW_BACKEND {backend!r}"
|
|
69
|
+
)
|
|
70
|
+
return None, what, backend
|
|
71
|
+
from outerloop.harness import vertex_from_env
|
|
72
|
+
|
|
73
|
+
api_key = os.environ.get(key_var, "").strip()
|
|
74
|
+
# an ADC-only deployment holds no Anthropic key: Vertex covering the
|
|
75
|
+
# claude backend stands in for it (same tolerance as role_key)
|
|
76
|
+
vertex_covers = backend == "claude" and vertex_from_env() is not None
|
|
77
|
+
if not api_key and not vertex_covers:
|
|
78
|
+
return None, f"{key_var} is unset or empty", backend
|
|
79
|
+
hermes_repo: Path | None = None
|
|
80
|
+
provider = ""
|
|
81
|
+
if backend == "hermes":
|
|
82
|
+
hermes_repo_env = os.environ.get("REVIEW_HERMES_REPO", "").strip()
|
|
83
|
+
if not hermes_repo_env:
|
|
84
|
+
return None, "REVIEW_HERMES_REPO is unset (hermes needs its pinned clone)", backend
|
|
85
|
+
hermes_repo = Path(hermes_repo_env).resolve()
|
|
86
|
+
provider = hermes_provider
|
|
87
|
+
if provider == "openrouter" and review_model and "/" not in review_model:
|
|
88
|
+
return (
|
|
89
|
+
None,
|
|
90
|
+
"hermes+openrouter requires an OpenRouter-shaped REVIEW_MODEL "
|
|
91
|
+
"(openai/gpt-5.6-terra, with the provider prefix)",
|
|
92
|
+
backend,
|
|
93
|
+
)
|
|
94
|
+
if provider == "openai" and (not review_model or "/" in review_model):
|
|
95
|
+
return (
|
|
96
|
+
None,
|
|
97
|
+
"hermes+openai requires a provider-native REVIEW_MODEL "
|
|
98
|
+
"(gpt-5.6-terra, no openai/ prefix)",
|
|
99
|
+
backend,
|
|
100
|
+
)
|
|
101
|
+
harness = build_harness(
|
|
102
|
+
api_key,
|
|
103
|
+
spec,
|
|
104
|
+
backend=backend,
|
|
105
|
+
binary=os.environ.get("REVIEW_BINARY") or None, # else the backend default on PATH
|
|
106
|
+
model=review_model or None,
|
|
107
|
+
hermes_repo=hermes_repo,
|
|
108
|
+
hermes_provider=provider,
|
|
109
|
+
)
|
|
110
|
+
return harness, "", backend
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def main() -> int:
|
|
114
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
115
|
+
# Fail closed on a missing/invalid PR reference too, so a misconfigured
|
|
116
|
+
# workflow skips cleanly rather than exiting nonzero (never red the CI).
|
|
117
|
+
repo = os.environ.get("PR_REPO", "").strip()
|
|
118
|
+
number_raw = os.environ.get("PR_NUMBER", "").strip()
|
|
119
|
+
if not repo or not number_raw.isdigit():
|
|
120
|
+
log.warning("PR_REPO/PR_NUMBER unset or invalid; skipping")
|
|
121
|
+
return 0
|
|
122
|
+
number = int(number_raw)
|
|
123
|
+
emit_env = os.environ.get("REVIEW_EMIT_FILE", "").strip()
|
|
124
|
+
# Fail closed: without the bot login we cannot honor "never review
|
|
125
|
+
# bot-authored PRs", so we do not review at all.
|
|
126
|
+
bot_login = os.environ.get("REVIEW_BOT_LOGIN", "").strip()
|
|
127
|
+
if not bot_login:
|
|
128
|
+
_skip_stub(emit_env, repo, number, "REVIEW_BOT_LOGIN is unset", "")
|
|
129
|
+
return 0
|
|
130
|
+
# Backend is a deployment choice, not baked in: the shared resolver owns
|
|
131
|
+
# the REVIEW_BACKEND/REVIEW_MODEL/REVIEW_HERMES_*/key-var contract (also
|
|
132
|
+
# used by the summarizer CLI); any config that must skip becomes a
|
|
133
|
+
# PR-visible stub, never a silent log line or a traceback.
|
|
134
|
+
# the backend label for pre-harness stubs (attribution only; the resolver
|
|
135
|
+
# below re-derives it authoritatively)
|
|
136
|
+
backend = os.environ.get("REVIEW_BACKEND", "claude").lower()
|
|
137
|
+
# The workflow checks out the PR head into REVIEW_CHECKOUT for the agent
|
|
138
|
+
# to investigate (it records its verdict via the syscall tool). Fail closed:
|
|
139
|
+
# defaulting to cwd would silently review the wrong tree (the reviewer's
|
|
140
|
+
# own repo) if the checkout step were misconfigured.
|
|
141
|
+
checkout = os.environ.get("REVIEW_CHECKOUT", "").strip()
|
|
142
|
+
if not checkout:
|
|
143
|
+
_skip_stub(
|
|
144
|
+
emit_env,
|
|
145
|
+
repo,
|
|
146
|
+
number,
|
|
147
|
+
"REVIEW_CHECKOUT is unset (won't review the wrong tree)",
|
|
148
|
+
backend,
|
|
149
|
+
)
|
|
150
|
+
return 0
|
|
151
|
+
workspace = Path(checkout).resolve()
|
|
152
|
+
# The checkout is untrusted: rename instruction files (CLAUDE.md, .claude/
|
|
153
|
+
# hooks, ...) so no backend auto-loads PR content as instructions. A rename
|
|
154
|
+
# failure means an instruction file is still live — fail closed.
|
|
155
|
+
renamed, failed = sanitize_checkout(workspace)
|
|
156
|
+
if failed:
|
|
157
|
+
_skip_stub(
|
|
158
|
+
emit_env,
|
|
159
|
+
repo,
|
|
160
|
+
number,
|
|
161
|
+
f"checkout could not be fully sanitized ({failed} instruction files left)",
|
|
162
|
+
backend,
|
|
163
|
+
)
|
|
164
|
+
return 0
|
|
165
|
+
if renamed:
|
|
166
|
+
log.info("sanitized %d instruction file(s) in the checkout", renamed)
|
|
167
|
+
|
|
168
|
+
lens = os.environ.get("REVIEW_LENS", "").strip()
|
|
169
|
+
if lens:
|
|
170
|
+
from outerloop.review import REVIEW_LENSES
|
|
171
|
+
|
|
172
|
+
if lens != "general" and lens not in REVIEW_LENSES:
|
|
173
|
+
# a typo'd lens must be a PR-visible stub, never a silent
|
|
174
|
+
# default-review (a configured lens must never quietly vanish)
|
|
175
|
+
_skip_stub(
|
|
176
|
+
emit_env,
|
|
177
|
+
repo,
|
|
178
|
+
number,
|
|
179
|
+
f"unknown REVIEW_LENS {lens!r} (have: {sorted(REVIEW_LENSES)})",
|
|
180
|
+
backend,
|
|
181
|
+
)
|
|
182
|
+
return 0
|
|
183
|
+
|
|
184
|
+
harness, why, backend = resolve_reviewer_harness(reviewer_spec())
|
|
185
|
+
if harness is None:
|
|
186
|
+
_skip_stub(emit_env, repo, number, why, backend)
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
client = GitHubClient(auth=EnvTokenProvider("GITHUB_TOKEN"))
|
|
190
|
+
# Least-token split: with REVIEW_EMIT_FILE set, findings are written there
|
|
191
|
+
# instead of posted — this job then needs only READ permissions, and a
|
|
192
|
+
# separate posting job (review_post_cli) holds the write token with no
|
|
193
|
+
# session next to it. This is what makes non-Claude backends safe on the
|
|
194
|
+
# auto path (docs/design/reviewer-infra.md).
|
|
195
|
+
run_agent_review(
|
|
196
|
+
client,
|
|
197
|
+
repo,
|
|
198
|
+
number,
|
|
199
|
+
harness,
|
|
200
|
+
workspace,
|
|
201
|
+
bot_login=bot_login,
|
|
202
|
+
emit_path=Path(emit_env).resolve() if emit_env else None,
|
|
203
|
+
lens=lens,
|
|
204
|
+
)
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
if __name__ == "__main__":
|
|
209
|
+
sys.exit(main())
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Posting half of the least-token split.
|
|
2
|
+
|
|
3
|
+
Reads the findings file the session job emitted (`REVIEW_EMIT_FILE`),
|
|
4
|
+
re-validates it, and posts through the normal advisory path. This job holds
|
|
5
|
+
the write token; no model session runs next to it. The artifact crosses a
|
|
6
|
+
job boundary, so nothing in it is trusted: the envelope must name this PR,
|
|
7
|
+
the skip rules are re-checked, and every string passes the same sanitizing
|
|
8
|
+
render as the single-job path. Exits 0 on every failure — advisory means
|
|
9
|
+
advisory.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from outerloop.github import EnvTokenProvider, GitHubClient
|
|
21
|
+
from outerloop.posting import EXPECTED_FAILURES, post_round_review, post_skip_stub
|
|
22
|
+
from outerloop.review import (
|
|
23
|
+
MARKER,
|
|
24
|
+
format_comment,
|
|
25
|
+
format_review,
|
|
26
|
+
result_from_data,
|
|
27
|
+
sanitize,
|
|
28
|
+
skip_reason,
|
|
29
|
+
)
|
|
30
|
+
from outerloop.review_agent import _pull_request
|
|
31
|
+
|
|
32
|
+
log = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
# An opinion label rides into the round body so a human can tell the second
|
|
35
|
+
# opinion from the primary reviewer at a glance. Caller-configured, but
|
|
36
|
+
# rendered into a comment: length-capped and newline-stripped.
|
|
37
|
+
MAX_OPINION_LABEL = 80
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def post_from_file(
|
|
41
|
+
client: GitHubClient,
|
|
42
|
+
repo: str,
|
|
43
|
+
number: int,
|
|
44
|
+
bot_login: str,
|
|
45
|
+
path: Path,
|
|
46
|
+
opinion_label: str = "",
|
|
47
|
+
) -> str | None:
|
|
48
|
+
"""Post the emitted findings (or skip stub). Returns the round label, or
|
|
49
|
+
None when there was nothing to post or the envelope was refused."""
|
|
50
|
+
try:
|
|
51
|
+
envelope = json.loads(path.read_text())
|
|
52
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
53
|
+
log.warning("findings file unreadable (%s); nothing posted", exc)
|
|
54
|
+
return None
|
|
55
|
+
if not isinstance(envelope, dict):
|
|
56
|
+
log.warning("findings file is not an object; nothing posted")
|
|
57
|
+
return None
|
|
58
|
+
if envelope.get("repo") != repo or envelope.get("number") != number:
|
|
59
|
+
log.warning("findings envelope names a different PR; refused")
|
|
60
|
+
return None
|
|
61
|
+
kind = envelope.get("kind")
|
|
62
|
+
if kind == "skip-clean":
|
|
63
|
+
# a clean skip (bot PR, opt-out) posts nothing by design; the
|
|
64
|
+
# envelope exists so a MISSING artifact always means a broken session
|
|
65
|
+
log.info("session skipped cleanly (%s); nothing to post", envelope.get("detail", ""))
|
|
66
|
+
return None
|
|
67
|
+
if kind not in ("skip-stub", "findings"):
|
|
68
|
+
log.warning("unknown envelope kind %r; nothing posted", kind)
|
|
69
|
+
return None
|
|
70
|
+
try:
|
|
71
|
+
# The write authority re-checks the skip rules for EVERY kind: the
|
|
72
|
+
# session job decided them once, but this side of the artifact
|
|
73
|
+
# boundary is the one that must never post on a bot PR — a forged
|
|
74
|
+
# stub envelope is still a post.
|
|
75
|
+
pr, pr_data = _pull_request(client, repo, number)
|
|
76
|
+
skip = skip_reason(pr, bot_login)
|
|
77
|
+
if skip is not None:
|
|
78
|
+
log.info("skipping post on %s#%s: %s", repo, number, skip)
|
|
79
|
+
return None
|
|
80
|
+
if kind == "skip-stub":
|
|
81
|
+
# sanitize: the detail crossed the job boundary and lands in a
|
|
82
|
+
# comment — collapse newlines, neutralize markdown, cap length
|
|
83
|
+
detail = sanitize(str(envelope.get("detail", "")), 300)
|
|
84
|
+
# name WHOSE round failed: with two standing reviewers, an
|
|
85
|
+
# unattributed stub is ambiguous exactly when a key dies
|
|
86
|
+
who = " ".join(opinion_label.split())[:MAX_OPINION_LABEL] or str(
|
|
87
|
+
envelope.get("reviewed_by", "")
|
|
88
|
+
)
|
|
89
|
+
role = f"advisory review ({who})" if who else "advisory review"
|
|
90
|
+
post_skip_stub(client, repo, number, role, RuntimeError(detail))
|
|
91
|
+
return "skip-stub"
|
|
92
|
+
data = envelope.get("data")
|
|
93
|
+
review = result_from_data(data if isinstance(data, dict) else {})
|
|
94
|
+
rendered = format_review(review, pr.diff)
|
|
95
|
+
full = format_comment(review)
|
|
96
|
+
if rendered is None or full is None:
|
|
97
|
+
log.info("nothing to post")
|
|
98
|
+
return None
|
|
99
|
+
body, inline = rendered
|
|
100
|
+
if opinion_label:
|
|
101
|
+
# AFTER the marker, never before it: round counting and the
|
|
102
|
+
# quote-reply defense both match marker-FIRST bodies
|
|
103
|
+
label = " ".join(opinion_label.split())[:MAX_OPINION_LABEL]
|
|
104
|
+
body = body.replace(MARKER, f"{MARKER}\n*{label}*", 1)
|
|
105
|
+
full = full.replace(MARKER, f"{MARKER}\n*{label}*", 1)
|
|
106
|
+
round_label = post_round_review(
|
|
107
|
+
client,
|
|
108
|
+
repo,
|
|
109
|
+
number,
|
|
110
|
+
MARKER,
|
|
111
|
+
body,
|
|
112
|
+
inline,
|
|
113
|
+
pr_data,
|
|
114
|
+
fallback_body=full,
|
|
115
|
+
reviewed_by=str(envelope.get("reviewed_by", "")),
|
|
116
|
+
)
|
|
117
|
+
log.info(
|
|
118
|
+
"posted review (%s) on %s#%s (reviewer=%s)",
|
|
119
|
+
round_label,
|
|
120
|
+
repo,
|
|
121
|
+
number,
|
|
122
|
+
str(envelope.get("reviewed_by", "")) or "unattributed",
|
|
123
|
+
)
|
|
124
|
+
return round_label
|
|
125
|
+
except EXPECTED_FAILURES as exc: # advisory: never fail the target repo's CI
|
|
126
|
+
log.warning("posting did not complete: %s: %s", type(exc).__name__, exc)
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main() -> int:
|
|
131
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
132
|
+
repo = os.environ.get("PR_REPO", "").strip()
|
|
133
|
+
number_raw = os.environ.get("PR_NUMBER", "").strip()
|
|
134
|
+
if not repo or not number_raw.isdigit():
|
|
135
|
+
log.warning("PR_REPO/PR_NUMBER unset or invalid; skipping")
|
|
136
|
+
return 0
|
|
137
|
+
bot_login = os.environ.get("REVIEW_BOT_LOGIN", "").strip()
|
|
138
|
+
if not bot_login:
|
|
139
|
+
log.warning("REVIEW_BOT_LOGIN is unset; skipping (cannot re-check the bot skip)")
|
|
140
|
+
return 0
|
|
141
|
+
emit_file = os.environ.get("REVIEW_EMIT_FILE", "").strip()
|
|
142
|
+
if not emit_file:
|
|
143
|
+
log.warning("REVIEW_EMIT_FILE is unset; skipping")
|
|
144
|
+
return 0
|
|
145
|
+
path = Path(emit_file).resolve()
|
|
146
|
+
if not path.is_file():
|
|
147
|
+
log.info("no findings file at %s (clean skip upstream); nothing to post", path)
|
|
148
|
+
return 0
|
|
149
|
+
client = GitHubClient(auth=EnvTokenProvider("GITHUB_TOKEN"))
|
|
150
|
+
post_from_file(
|
|
151
|
+
client,
|
|
152
|
+
repo,
|
|
153
|
+
int(number_raw),
|
|
154
|
+
bot_login,
|
|
155
|
+
path,
|
|
156
|
+
opinion_label=os.environ.get("REVIEW_OPINION_LABEL", "").strip(),
|
|
157
|
+
)
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
sys.exit(main())
|