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.
Files changed (52) hide show
  1. outerloop/__init__.py +18 -0
  2. outerloop/__main__.py +3 -0
  3. outerloop/appauth.py +213 -0
  4. outerloop/appmanifest.py +198 -0
  5. outerloop/attempt.py +3481 -0
  6. outerloop/brief.py +515 -0
  7. outerloop/cli.py +439 -0
  8. outerloop/climbboard.py +1145 -0
  9. outerloop/compute.py +482 -0
  10. outerloop/contract.py +483 -0
  11. outerloop/contract_cli.py +63 -0
  12. outerloop/disk.py +164 -0
  13. outerloop/dispatch.py +586 -0
  14. outerloop/followup.py +2143 -0
  15. outerloop/github.py +1486 -0
  16. outerloop/harness.py +1449 -0
  17. outerloop/housekeeping.py +167 -0
  18. outerloop/init.py +313 -0
  19. outerloop/intake.py +129 -0
  20. outerloop/limits.py +80 -0
  21. outerloop/markers.py +48 -0
  22. outerloop/measure.py +523 -0
  23. outerloop/orchestrator.py +1901 -0
  24. outerloop/panel.py +188 -0
  25. outerloop/paths.py +27 -0
  26. outerloop/posting.py +160 -0
  27. outerloop/progress.py +170 -0
  28. outerloop/py.typed +0 -0
  29. outerloop/review.py +611 -0
  30. outerloop/review_agent.py +263 -0
  31. outerloop/review_agent_cli.py +209 -0
  32. outerloop/review_post_cli.py +162 -0
  33. outerloop/review_summarize_cli.py +163 -0
  34. outerloop/role_runner.py +229 -0
  35. outerloop/roles.py +247 -0
  36. outerloop/rolespec.py +89 -0
  37. outerloop/runstate.py +385 -0
  38. outerloop/steward.py +852 -0
  39. outerloop/style.py +12 -0
  40. outerloop/syscall.py +977 -0
  41. outerloop/syscall_cli.py +531 -0
  42. outerloop/tick.py +3166 -0
  43. outerloop/verifier.py +403 -0
  44. outerloop/verify_agent.py +149 -0
  45. outerloop/verify_agent_cli.py +95 -0
  46. outerloop/verify_post_cli.py +116 -0
  47. outerloop_science-0.1.0.dev0.dist-info/METADATA +145 -0
  48. outerloop_science-0.1.0.dev0.dist-info/RECORD +52 -0
  49. outerloop_science-0.1.0.dev0.dist-info/WHEEL +4 -0
  50. outerloop_science-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  51. outerloop_science-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
  52. 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())