outerloop-science 0.1.0__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 (59) hide show
  1. outerloop/__init__.py +18 -0
  2. outerloop/__main__.py +3 -0
  3. outerloop/appauth.py +230 -0
  4. outerloop/appmanifest.py +203 -0
  5. outerloop/attempt.py +3784 -0
  6. outerloop/brief.py +528 -0
  7. outerloop/cli.py +621 -0
  8. outerloop/climbboard.py +1395 -0
  9. outerloop/compute.py +654 -0
  10. outerloop/contract.py +492 -0
  11. outerloop/contract_cli.py +63 -0
  12. outerloop/disk.py +164 -0
  13. outerloop/dispatch.py +631 -0
  14. outerloop/evalcache.py +147 -0
  15. outerloop/followup.py +2172 -0
  16. outerloop/github.py +1531 -0
  17. outerloop/harness.py +1435 -0
  18. outerloop/housekeeping.py +151 -0
  19. outerloop/image.py +368 -0
  20. outerloop/init.py +744 -0
  21. outerloop/intake.py +126 -0
  22. outerloop/launchlog.py +239 -0
  23. outerloop/limits.py +80 -0
  24. outerloop/maintain.py +353 -0
  25. outerloop/maintain_agent_cli.py +81 -0
  26. outerloop/maintain_post_cli.py +140 -0
  27. outerloop/markers.py +48 -0
  28. outerloop/measure.py +529 -0
  29. outerloop/orchestrator.py +2011 -0
  30. outerloop/panel.py +188 -0
  31. outerloop/paths.py +40 -0
  32. outerloop/posting.py +160 -0
  33. outerloop/progress.py +170 -0
  34. outerloop/py.typed +0 -0
  35. outerloop/review.py +615 -0
  36. outerloop/review_agent.py +263 -0
  37. outerloop/review_agent_cli.py +209 -0
  38. outerloop/review_post_cli.py +162 -0
  39. outerloop/review_summarize_cli.py +165 -0
  40. outerloop/role_runner.py +229 -0
  41. outerloop/roles.py +274 -0
  42. outerloop/rolespec.py +91 -0
  43. outerloop/runstate.py +385 -0
  44. outerloop/steward.py +845 -0
  45. outerloop/style.py +12 -0
  46. outerloop/syscall.py +1192 -0
  47. outerloop/syscall_cli.py +762 -0
  48. outerloop/tick.py +3422 -0
  49. outerloop/verifier.py +403 -0
  50. outerloop/verify_agent.py +151 -0
  51. outerloop/verify_agent_cli.py +95 -0
  52. outerloop/verify_post_cli.py +116 -0
  53. outerloop/watcher.py +203 -0
  54. outerloop_science-0.1.0.dist-info/METADATA +152 -0
  55. outerloop_science-0.1.0.dist-info/RECORD +59 -0
  56. outerloop_science-0.1.0.dist-info/WHEEL +4 -0
  57. outerloop_science-0.1.0.dist-info/entry_points.txt +2 -0
  58. outerloop_science-0.1.0.dist-info/licenses/LICENSE +202 -0
  59. outerloop_science-0.1.0.dist-info/licenses/NOTICE +5 -0
outerloop/maintain.py ADDED
@@ -0,0 +1,353 @@
1
+ """The maintenance scan: a read-only agent session over a checkout of a
2
+ repository's default branch that records cleanup, upgrade, test-health and
3
+ performance items as findings, and the digest those findings render into.
4
+
5
+ It reuses the reviewer's machinery — the FINDINGS_SCHEMA verdict through the
6
+ syscall tool, lenses fanned out and merged by the summarizer, the emit/post
7
+ split — and differs in three places: the brief scans a tree instead of a
8
+ diff, nothing is blocking, and the destination is one rolling issue
9
+ (docs/design/reviewer-infra.md, "Maintenance scan"). Any repository can run
10
+ it from the reusable workflow; the brief assumes nothing about this one."""
11
+
12
+ from __future__ import annotations
13
+
14
+ import contextlib
15
+ import logging
16
+ import urllib.parse
17
+ from collections.abc import Iterable
18
+ from datetime import UTC, datetime
19
+ from pathlib import Path
20
+
21
+ from outerloop.harness import Harness, backend_id
22
+ from outerloop.markers import marker
23
+ from outerloop.posting import EXPECTED_FAILURES
24
+ from outerloop.review import DEFAULT_SYSCALL_CMD, Finding, ReviewResult, sanitize
25
+ from outerloop.review_agent import emit_envelope
26
+ from outerloop.role_runner import run_role
27
+ from outerloop.rolespec import RoleSpec
28
+
29
+ log = logging.getLogger(__name__)
30
+
31
+ MARKER = marker("maintenance-digest")
32
+ DIGEST_TITLE = "Maintainer digest"
33
+ ADVISORY = (
34
+ "*Advisory findings from `outerloop`. The maintainer decides: items marked "
35
+ "**Decision** need a call before any change; the rest are mechanical and may "
36
+ "be taken as work orders. The scan edits nothing.*"
37
+ )
38
+
39
+ # Each lens is one section of the digest; `general` is the whole checklist.
40
+ # The library lives here; which lenses run is the caller workflow's matrix.
41
+ MAINTENANCE_LENSES: dict[str, str] = {
42
+ "pathways": (
43
+ "LENS — dead and unused pathways: symbols, CLI flags, config keys and "
44
+ "environment knobs with no caller or no documentation; compatibility "
45
+ "shims and what each still guards; test-only code living in the "
46
+ "package. Grep across source, tests, scripts and docs before calling "
47
+ "anything unused."
48
+ ),
49
+ "duplication": (
50
+ "LENS — logic with more than one owner: the same rule implemented in "
51
+ "two places (two parsers of one file format, two copies of one "
52
+ "sequence), private helpers imported across modules, argument groups "
53
+ "or fixtures copied between entry points or test files, version pins "
54
+ "repeated in several files."
55
+ ),
56
+ "structure": (
57
+ "LENS — size and shape: the largest modules and longest functions "
58
+ "(measure them), import cycles and the in-function imports that hide "
59
+ "them, templates or data embedded in code, and the natural seams a "
60
+ "split would follow."
61
+ ),
62
+ "upgrades": (
63
+ "LENS — dependencies and tooling: pinned versions against the latest "
64
+ "available (the package index, GitHub releases), CI action versions, "
65
+ "runner images, linter and type-checker settings that could be "
66
+ "tightened cheaply, interpreter versions exercised. Name the pin and "
67
+ "the current upstream for each."
68
+ ),
69
+ "tests": (
70
+ "LENS — test-suite health: the slowest tests and why, real sleeps and "
71
+ "real subprocesses where a fake would do, fixtures and fakes defined "
72
+ "several times, tests that no longer pin the behavior they name, "
73
+ "markers declared but unused."
74
+ ),
75
+ "performance": (
76
+ "LENS — repeated work on the hot path: find the loop or entry point "
77
+ "the repository runs most often and count what it re-reads, re-lists "
78
+ "or re-fetches per iteration and per record; caching that is missing, "
79
+ "network calls without conditional requests, files parsed more than "
80
+ "once."
81
+ ),
82
+ "docs": (
83
+ "LENS — documentation drift: comments that narrate history instead of "
84
+ "intent, changelog sections to consolidate, roadmap or design notes "
85
+ "whose status no longer matches the code, knobs and flags the docs "
86
+ "never name, wording that disagrees between two documents."
87
+ ),
88
+ "architecture": (
89
+ "LENS — abstraction and extensibility, forward-looking rather than "
90
+ "cleanup: where two abstractions could be unified or a layer dropped "
91
+ "so the system is simpler to reason about; and, holding the principle "
92
+ "that a new backend, benchmark, or role should need zero kernel "
93
+ "change, where an extension point is missing so adding one today "
94
+ "forces a kernel edit. Name the files and propose the merge or the "
95
+ "seam; mark these decisions — a refactor of an abstraction many "
96
+ "parts of the system depend on is the maintainer's call, not a "
97
+ "mechanical change."
98
+ ),
99
+ }
100
+
101
+ SYSTEM_PROMPT = (
102
+ "You are the maintainer's periodic scan of this repository. You read the whole "
103
+ "tree, measure rather than guess, and record each item worth doing as a finding. "
104
+ "Nothing you find blocks anything: the maintainer reads the digest and decides.\n\n"
105
+ "What to record: cleanup, simplification, upgrade, test-health and performance "
106
+ "items — what a careful maintainer would put on their own list after a week away. "
107
+ "Skip style nits a linter already reports and work the repository's own roadmap "
108
+ "already tracks as planned.\n\n"
109
+ "Evidence: every item names a file and a line, and a measured fact where one "
110
+ "exists (a line count, a call count, the pinned and the latest version, a test "
111
+ "duration). Say what you ran.\n\n"
112
+ "Shape of each finding:\n"
113
+ "- --kind change: mechanical, safe for an agent to do in a pull request without a "
114
+ "design call.\n"
115
+ "- --kind question: needs the maintainer's decision first (when to drop a "
116
+ "compatibility path, whether to re-verify a pinned tool, which module owns a "
117
+ "duplicated rule).\n"
118
+ "- --kind note: worth knowing, not worth a change.\n"
119
+ "- --category: the digest section the item belongs to, one of {sections}.\n"
120
+ '- --detail: start with effort and risk, for example "S, low." (S is under an '
121
+ "hour, M an afternoon, L a day or more; risk is what could break), then the "
122
+ "evidence.\n"
123
+ "- never --blocking.\n\n"
124
+ "Your concluding notes open the digest: one line on what is healthy, then the "
125
+ "three items most worth doing, one sentence each."
126
+ )
127
+
128
+
129
+ def _investigation(ref: str, syscall_cmd: str) -> str:
130
+ return (
131
+ f"The repository is checked out in your working directory at commit {ref}. "
132
+ "Use Read, Grep and Glob, and run read-only commands in the shell: line "
133
+ "counts, the test suite with durations, the package manager's outdated "
134
+ "list, the linter's statistics. Do not modify the tree, do not install "
135
+ "anything beyond what its own lockfile describes, and do not push or "
136
+ "post anything — your only product is the verdict.\n\n"
137
+ "Record each item as you confirm it, one command per item:\n"
138
+ f" {syscall_cmd} finding --file <path> [--line N] "
139
+ "--confidence <low|medium|high> --category <section> --summary <one line> "
140
+ "--detail <effort, risk, then the evidence> --kind <change|question|note>\n"
141
+ "When you are done, commit your verdict and end your turn:\n"
142
+ f" {syscall_cmd} conclude --notes <what is healthy; the three items most worth doing>\n"
143
+ "The verdict you commit is your final answer — do not also restate it in a message."
144
+ )
145
+
146
+
147
+ def build_maintenance_brief(
148
+ repo: str,
149
+ ref: str,
150
+ today: str | None = None,
151
+ *,
152
+ syscall_cmd: str = DEFAULT_SYSCALL_CMD,
153
+ lens: str = "",
154
+ ) -> str:
155
+ """The scan brief: the standing prompt, the lens (or every section for
156
+ `general`), the investigation instruction and the repository line. An
157
+ unknown lens fails loudly, as the reviewer's does."""
158
+ if lens and lens != "general" and lens not in MAINTENANCE_LENSES:
159
+ raise ValueError(f"unknown maintenance lens {lens!r} (have: {sorted(MAINTENANCE_LENSES)})")
160
+ sections = ", ".join(MAINTENANCE_LENSES)
161
+ if lens and lens != "general":
162
+ focus = MAINTENANCE_LENSES[lens]
163
+ else:
164
+ focus = "Cover every section:\n\n" + "\n\n".join(MAINTENANCE_LENSES.values())
165
+ header = f"Today's date: {today}\n" if today else ""
166
+ header += f"Repository: {repo} at {ref}"
167
+ return (
168
+ f"{SYSTEM_PROMPT.format(sections=sections)}\n\n{focus}\n\n"
169
+ f"{_investigation(ref, syscall_cmd)}\n\n{header}\n"
170
+ )
171
+
172
+
173
+ _KIND_ORDER = {"question": 0, "change": 1, "suggestion": 1, "note": 2}
174
+ _CONFIDENCE_ORDER = {"high": 0, "medium": 1, "low": 2}
175
+ _LABEL = {"question": "**Decision.** ", "note": "*Note.* "}
176
+
177
+
178
+ def _item(finding: Finding, repo: str, ref: str) -> str:
179
+ # backticks stripped: a file value containing one would close the code
180
+ # span and render model markdown inline (same rule as the review body)
181
+ safe_file = finding.file.replace("`", "")
182
+ where = f"`{safe_file}`" + (f":{finding.line}" if finding.line else "")
183
+ link = (
184
+ f"https://github.com/{repo}/blob/{urllib.parse.quote(ref)}/{urllib.parse.quote(safe_file)}"
185
+ )
186
+ if finding.line:
187
+ link += f"#L{finding.line}"
188
+ summary = finding.summary.rstrip(".!?…")
189
+ if summary.count("`") % 2:
190
+ summary += "`"
191
+ detail = finding.detail + ("`" if finding.detail.count("`") % 2 else "")
192
+ label = _LABEL.get(finding.kind, "")
193
+ return f"- {label}**{summary}.** {detail} ([{where}]({link}); {finding.confidence})"
194
+
195
+
196
+ def digest_title(today: str) -> str:
197
+ """The rolling issue's title, carrying the date of the digest it shows so
198
+ its freshness reads from the issue list. A successful scan sets it to that
199
+ scan's date; a scan that could not run keeps the last good digest — and this
200
+ date — so a stalled scan reads as stale rather than falsely fresh."""
201
+ return f"{DIGEST_TITLE} — {today}"
202
+
203
+
204
+ def render_digest(
205
+ result: ReviewResult,
206
+ *,
207
+ repo: str,
208
+ ref: str,
209
+ today: str,
210
+ reviewed_by: str,
211
+ ) -> str:
212
+ """The rolling issue's body: marker first, the header and the advisory
213
+ line, the counts, the scan's own summary, then one section per category
214
+ with decisions first. Every string in `result` is already sanitized by
215
+ `result_from_data`; the marker leads so the poster can find the issue."""
216
+ findings = result.findings
217
+ decisions = sum(1 for f in findings if f.kind == "question")
218
+ notes = sum(1 for f in findings if f.kind == "note")
219
+ mechanical = len(findings) - decisions - notes
220
+ who = sanitize(reviewed_by, 120) or "unattributed"
221
+ lines = [
222
+ MARKER,
223
+ f"**{DIGEST_TITLE}** — {repo} at `{ref[:8]}` on {today}; scanned by `{who}`.",
224
+ "",
225
+ ADVISORY,
226
+ "",
227
+ f"{len(findings)} items: {decisions} need a decision, {mechanical} are "
228
+ f"mechanical, {notes} are notes.",
229
+ "",
230
+ ]
231
+ if result.notes:
232
+ # keep the top a short summary (title, advisory, counts); the scan's
233
+ # own verdict and its rejected-findings reasoning fold away below it
234
+ lines += [
235
+ "<details><summary>Scan verdict and rejected findings</summary>",
236
+ "",
237
+ result.notes,
238
+ "",
239
+ "</details>",
240
+ "",
241
+ ]
242
+ by_section: dict[str, list[Finding]] = {}
243
+ for f in findings:
244
+ section = f.category if f.category in MAINTENANCE_LENSES else "other"
245
+ by_section.setdefault(section, []).append(f)
246
+ for section in [*MAINTENANCE_LENSES, "other"]:
247
+ items = by_section.get(section)
248
+ if not items:
249
+ continue
250
+ items.sort(key=lambda f: (_KIND_ORDER.get(f.kind, 2), _CONFIDENCE_ORDER[f.confidence]))
251
+ lines += [f"### {section}", ""]
252
+ lines += [_item(f, repo, ref) for f in items]
253
+ lines.append("")
254
+ lines.append(
255
+ "_Each scan replaces this body; earlier digests are in the edit history. "
256
+ "Run a scan by hand from the Actions tab (maintenance → Run workflow)._"
257
+ )
258
+ return "\n".join(lines).rstrip() + "\n"
259
+
260
+
261
+ def render_stub(detail: str, *, repo: str, ref: str, today: str, who: str) -> str:
262
+ """What the poster writes when the scan could not run: the reason, on the
263
+ digest issue, never silence."""
264
+ reason = sanitize(detail, 300)
265
+ by = f" ({sanitize(who, 120)})" if who else ""
266
+ return (
267
+ f"{MARKER}\n**{DIGEST_TITLE}** — the scan of {repo} at `{ref[:8]}` on {today} "
268
+ f"could not run{by}: {reason}"
269
+ )
270
+
271
+
272
+ def run_maintenance_scan(
273
+ repo: str,
274
+ ref: str,
275
+ harness: Harness,
276
+ workspace: Path,
277
+ *,
278
+ spec: RoleSpec | None = None,
279
+ emit_path: Path,
280
+ today: str | None = None,
281
+ lens: str = "",
282
+ ) -> str | None:
283
+ """One lens session over `workspace` (a default-branch checkout the caller
284
+ prepared and sanitized). EVERY outcome writes an envelope for the posting
285
+ job — findings, or a skip-stub naming why — so a missing artifact always
286
+ means a broken session. Returns "emitted", or None when it could not
287
+ produce a verdict. Advisory: never raises the expected failures."""
288
+ from outerloop.roles import maintainer_spec
289
+
290
+ spec = spec or maintainer_spec()
291
+ today = today or datetime.now(UTC).date().isoformat()
292
+ try:
293
+ from outerloop.syscall import tool_command
294
+
295
+ brief = build_maintenance_brief(
296
+ repo, ref, today, syscall_cmd=tool_command(workspace), lens=lens
297
+ )
298
+ role_result = run_role(spec, harness, brief, workspace)
299
+ if not role_result.ok or role_result.data is None:
300
+ detail = role_result.error or role_result.session.stop_reason
301
+ log.warning("maintenance scan produced no verdict on %s (%s): %s", repo, lens, detail)
302
+ emit_envelope(
303
+ emit_path,
304
+ repo,
305
+ 0,
306
+ kind="skip-stub",
307
+ detail=detail,
308
+ reviewed_by=backend_id(harness),
309
+ lens=lens,
310
+ )
311
+ return None
312
+ emit_envelope(
313
+ emit_path,
314
+ repo,
315
+ 0,
316
+ kind="findings",
317
+ data=role_result.data,
318
+ reviewed_by=backend_id(harness),
319
+ lens=lens,
320
+ )
321
+ cost = role_result.session.cost_usd
322
+ log.info(
323
+ "emitted maintenance findings for %s (%s; cost=%s turns=%d)",
324
+ repo,
325
+ lens or "general",
326
+ f"${cost:.2f}" if cost else "unreported",
327
+ role_result.session.num_turns,
328
+ )
329
+ return "emitted"
330
+ except EXPECTED_FAILURES as exc: # advisory: never red the repository's Actions
331
+ log.warning("maintenance scan did not complete: %s: %s", type(exc).__name__, exc)
332
+ with contextlib.suppress(Exception):
333
+ emit_envelope(
334
+ emit_path,
335
+ repo,
336
+ 0,
337
+ kind="skip-stub",
338
+ detail=f"{type(exc).__name__}: {exc}",
339
+ reviewed_by=backend_id(harness),
340
+ lens=lens,
341
+ )
342
+ return None
343
+
344
+
345
+ def lens_names(lenses: Iterable[str]) -> list[str]:
346
+ """The lens names a caller configured, `general` included, unknown ones
347
+ refused — so a misspelled matrix entry fails at configuration time."""
348
+ out = []
349
+ for name in lenses:
350
+ if name != "general" and name not in MAINTENANCE_LENSES:
351
+ raise ValueError(f"unknown maintenance lens {name!r}")
352
+ out.append(name)
353
+ return out
@@ -0,0 +1,81 @@
1
+ """Entry point for one maintenance-scan lens session (emit only).
2
+
3
+ Runs the scan as an agent over a default-branch checkout the workflow prepared
4
+ (REVIEW_CHECKOUT) and writes its findings to REVIEW_EMIT_FILE for the posting
5
+ job. Exits 0 on every outcome — an advisory scan must never turn a
6
+ repository's Actions red.
7
+
8
+ Env: MAINTAIN_REPO (owner/repo), MAINTAIN_REF (the checked-out commit),
9
+ REVIEW_CHECKOUT, REVIEW_EMIT_FILE, REVIEW_LENS; the backend contract is the
10
+ reviewer's (REVIEW_BACKEND / REVIEW_MODEL / REVIEW_HERMES_* / the key vars),
11
+ resolved by the same code.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import os
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from outerloop.maintain import run_maintenance_scan
22
+ from outerloop.review_agent import emit_envelope, sanitize_checkout
23
+ from outerloop.review_agent_cli import resolve_reviewer_harness
24
+ from outerloop.roles import maintainer_spec
25
+
26
+ log = logging.getLogger(__name__)
27
+
28
+
29
+ def main() -> int:
30
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
31
+ repo = os.environ.get("MAINTAIN_REPO", "").strip()
32
+ ref = os.environ.get("MAINTAIN_REF", "").strip()
33
+ if not repo or not ref:
34
+ log.warning("MAINTAIN_REPO/MAINTAIN_REF unset; skipping")
35
+ return 0
36
+ emit_env = os.environ.get("REVIEW_EMIT_FILE", "").strip()
37
+ lens = os.environ.get("REVIEW_LENS", "").strip()
38
+ backend = os.environ.get("REVIEW_BACKEND", "claude").lower()
39
+
40
+ def stub(detail: str) -> int:
41
+ log.warning("%s; skipping scan", detail)
42
+ if emit_env:
43
+ emit_envelope(
44
+ Path(emit_env).resolve(),
45
+ repo,
46
+ 0,
47
+ kind="skip-stub",
48
+ detail=detail,
49
+ reviewed_by=backend,
50
+ lens=lens,
51
+ )
52
+ return 0
53
+
54
+ if not emit_env:
55
+ return stub("REVIEW_EMIT_FILE is unset (nothing to hand to the posting job)")
56
+ # Fail closed on the tree: defaulting to cwd would scan the kernel's own
57
+ # checkout instead of the repository the workflow prepared.
58
+ checkout = os.environ.get("REVIEW_CHECKOUT", "").strip()
59
+ if not checkout:
60
+ return stub("REVIEW_CHECKOUT is unset (won't scan the wrong tree)")
61
+ workspace = Path(checkout).resolve()
62
+ if not workspace.is_dir():
63
+ return stub(f"REVIEW_CHECKOUT {workspace} is not a directory")
64
+ # instruction-bearing files in the scanned tree are data, never prompts
65
+ renamed, failed = sanitize_checkout(workspace)
66
+ if failed:
67
+ return stub(f"{failed} instruction file(s) could not be neutralized in the checkout")
68
+ if renamed:
69
+ log.info("neutralized %d instruction file(s) in the checkout", renamed)
70
+ spec = maintainer_spec()
71
+ harness, why, _backend = resolve_reviewer_harness(spec)
72
+ if harness is None:
73
+ return stub(why)
74
+ run_maintenance_scan(
75
+ repo, ref, harness, workspace, spec=spec, emit_path=Path(emit_env).resolve(), lens=lens
76
+ )
77
+ return 0
78
+
79
+
80
+ if __name__ == "__main__":
81
+ sys.exit(main())
@@ -0,0 +1,140 @@
1
+ """Post a maintenance digest: read the envelope the scan (or the summarizer)
2
+ emitted, render it, and upsert the one rolling digest issue. The write token
3
+ lives only here — the session jobs are read-only — the same split as the
4
+ reviewer. Exits 0 on every outcome.
5
+
6
+ Env: GITHUB_TOKEN, MAINTAIN_REPO, MAINTAIN_REF, REVIEW_EMIT_FILE,
7
+ MAINTAIN_BOT_LOGIN (the login the token posts as; the digest issue must be
8
+ its own), REVIEW_OPINION_LABEL (optional attribution shown in the header).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import os
16
+ import sys
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path
19
+
20
+ from outerloop.github import EnvTokenProvider, GitHubClient
21
+ from outerloop.maintain import MARKER, digest_title, render_digest, render_stub
22
+ from outerloop.posting import EXPECTED_FAILURES
23
+ from outerloop.review import result_from_data
24
+
25
+ log = logging.getLogger(__name__)
26
+
27
+
28
+ DEFAULT_BOT_LOGIN = "github-actions[bot]" # what a workflow's own token posts as
29
+
30
+
31
+ def find_digest_issue(client: GitHubClient, repo: str, bot_login: str) -> int | None:
32
+ """The open issue carrying the digest marker among those `bot_login`
33
+ itself opened, or None. Only the poster's own issue is ever rewritten: a
34
+ person or another bot who pastes the marker into their issue keeps their
35
+ text, and asking GitHub for one author's issues keeps the lookup small
36
+ however many issues the repository has."""
37
+ for issue in client.list_open_issues(repo, creator=bot_login):
38
+ login = str((issue.get("user") or {}).get("login", ""))
39
+ if login.casefold() != bot_login.casefold():
40
+ continue
41
+ if MARKER in str(issue.get("body") or ""):
42
+ return int(issue["number"])
43
+ return None
44
+
45
+
46
+ def post_digest(
47
+ client: GitHubClient,
48
+ repo: str,
49
+ ref: str,
50
+ path: Path,
51
+ *,
52
+ bot_login: str = DEFAULT_BOT_LOGIN,
53
+ today: str | None = None,
54
+ opinion_label: str = "",
55
+ ) -> str | None:
56
+ """Post the emitted digest (or the could-not-run stub). Returns
57
+ "created", "updated" or "skip-stub", or None when nothing was posted."""
58
+ today = today or datetime.now(UTC).date().isoformat()
59
+ try:
60
+ envelope = json.loads(path.read_text())
61
+ except (OSError, json.JSONDecodeError) as exc:
62
+ log.warning("findings file unreadable (%s); nothing posted", exc)
63
+ return None
64
+ if not isinstance(envelope, dict):
65
+ log.warning("findings file is not an object; nothing posted")
66
+ return None
67
+ # a scan envelope names the repository and carries number 0: anything
68
+ # else is a review envelope in the wrong pipeline
69
+ if envelope.get("repo") != repo or envelope.get("number") != 0:
70
+ log.warning("envelope names a different repository or a pull request; refused")
71
+ return None
72
+ kind = envelope.get("kind")
73
+ if kind == "skip-clean":
74
+ log.info("scan skipped cleanly (%s); nothing to post", envelope.get("detail", ""))
75
+ return None
76
+ if kind not in ("skip-stub", "findings"):
77
+ log.warning("unknown envelope kind %r; nothing posted", kind)
78
+ return None
79
+ who = " ".join(opinion_label.split())[:60] or str(envelope.get("reviewed_by", ""))
80
+ try:
81
+ number = find_digest_issue(client, repo, bot_login)
82
+ if kind == "skip-stub":
83
+ body = render_stub(
84
+ str(envelope.get("detail", "")), repo=repo, ref=ref, today=today, who=who
85
+ )
86
+ if number is None:
87
+ client.create_issue(repo, digest_title(today), body)
88
+ else:
89
+ client.comment(repo, number, body)
90
+ return "skip-stub"
91
+ data = envelope.get("data")
92
+ result = result_from_data(data if isinstance(data, dict) else {})
93
+ body = render_digest(result, repo=repo, ref=ref, today=today, reviewed_by=who)
94
+ if number is None:
95
+ number = client.create_issue(repo, digest_title(today), body)
96
+ log.info("opened the digest issue %s#%s", repo, number)
97
+ return "created"
98
+ client.update_issue(repo, number, body, title=digest_title(today))
99
+ # a body edit notifies nobody; the comment does
100
+ client.comment(
101
+ repo,
102
+ number,
103
+ f"Digest updated for `{ref[:8]}` on {today}: {len(result.findings)} items.",
104
+ )
105
+ log.info("updated the digest issue %s#%s", repo, number)
106
+ return "updated"
107
+ except EXPECTED_FAILURES as exc: # advisory: never red the repository's Actions
108
+ log.warning("posting did not complete: %s: %s", type(exc).__name__, exc)
109
+ return None
110
+
111
+
112
+ def main() -> int:
113
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
114
+ repo = os.environ.get("MAINTAIN_REPO", "").strip()
115
+ ref = os.environ.get("MAINTAIN_REF", "").strip()
116
+ if not repo or not ref:
117
+ log.warning("MAINTAIN_REPO/MAINTAIN_REF unset; skipping")
118
+ return 0
119
+ emit_file = os.environ.get("REVIEW_EMIT_FILE", "").strip()
120
+ if not emit_file:
121
+ log.warning("REVIEW_EMIT_FILE is unset; skipping")
122
+ return 0
123
+ path = Path(emit_file).resolve()
124
+ if not path.is_file():
125
+ log.info("no findings file at %s; nothing to post", path)
126
+ return 0
127
+ client = GitHubClient(auth=EnvTokenProvider("GITHUB_TOKEN"))
128
+ post_digest(
129
+ client,
130
+ repo,
131
+ ref,
132
+ path,
133
+ bot_login=os.environ.get("MAINTAIN_BOT_LOGIN", "").strip() or DEFAULT_BOT_LOGIN,
134
+ opinion_label=os.environ.get("REVIEW_OPINION_LABEL", "").strip(),
135
+ )
136
+ return 0
137
+
138
+
139
+ if __name__ == "__main__":
140
+ sys.exit(main())
outerloop/markers.py ADDED
@@ -0,0 +1,48 @@
1
+ """Body markers and labels the kernel writes and later recognizes.
2
+
3
+ The kernel finds its own past comments, issues, and claims by an HTML-comment
4
+ marker (`<!-- outerloop:advisory-review -->`), and routes work by labels
5
+ (`outerloop:review`). Both are WRITTEN under the new `outerloop:` prefix and
6
+ RECOGNIZED under both prefixes — a reviewer that failed to see its own earlier
7
+ `autoresearch:` comment would post a duplicate, and a target's existing
8
+ `autoresearch:steward` issues must keep routing. Reads go through `has_marker` /
9
+ `has_label`; writes and documentation use `marker` / `label_name`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Iterable
15
+
16
+ NEW = "outerloop"
17
+ LEGACY = "autoresearch"
18
+ PREFIXES: tuple[str, ...] = (NEW, LEGACY)
19
+
20
+
21
+ def marker(kind: str) -> str:
22
+ """The marker we write for `kind`, e.g. `<!-- outerloop:followup -->`."""
23
+ return f"<!-- {NEW}:{kind} -->"
24
+
25
+
26
+ def legacy_marker(kind: str) -> str:
27
+ """The pre-rename marker for `kind`; only for finding old text of ours."""
28
+ return f"<!-- {LEGACY}:{kind} -->"
29
+
30
+
31
+ def has_marker(body: str, kind: str) -> bool:
32
+ """Does `body` carry the `kind` marker under either prefix?"""
33
+ return any(f"<!-- {prefix}:{kind} -->" in body for prefix in PREFIXES)
34
+
35
+
36
+ def label_name(kind: str) -> str:
37
+ """The label we apply and document for `kind`, e.g. `outerloop:review`."""
38
+ return f"{NEW}:{kind}"
39
+
40
+
41
+ def is_label(name: str, kind: str) -> bool:
42
+ """Is `name` the `kind` label under either prefix? Case-insensitive, as
43
+ GitHub label matching is."""
44
+ return name.casefold() in {f"{prefix}:{kind}" for prefix in PREFIXES}
45
+
46
+
47
+ def has_label(labels: Iterable[str], kind: str) -> bool:
48
+ return any(is_label(name, kind) for name in labels)