From 2fc3874188de313a8f9008f93c06ff0020f90ff8 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 19:50:11 -0600 Subject: [PATCH 01/31] Add an allowlisted gh runner that never puts free text on argv Signed-off-by: rocklambros --- tests/test_fetch_roadmap.py | 23 ++-- tests/test_gh_runner.py | 200 ++++++++++++++++++++++++++++++ tests/test_roadmap_sync_health.py | 2 +- tools/fetch_roadmap.py | 42 ++----- tools/gh_runner.py | 171 +++++++++++++++++++++++++ tools/roadmap_sync.py | 10 +- 6 files changed, 401 insertions(+), 47 deletions(-) create mode 100644 tests/test_gh_runner.py create mode 100644 tools/gh_runner.py diff --git a/tests/test_fetch_roadmap.py b/tests/test_fetch_roadmap.py index 1b14f9b7..238934f7 100644 --- a/tests/test_fetch_roadmap.py +++ b/tests/test_fetch_roadmap.py @@ -52,9 +52,11 @@ def http(status: int, body: dict | str, headers: dict | None = None) -> str: def fake_run(responses): calls = [] - def run(args): - calls.append(args) - query = next(a for a in args if a.startswith("query=")) + def run(args, stdin=None): + assert args == ["api", "-i", "graphql", "--input", "-"] + request = json.loads(stdin) + calls.append(request) + query = request["query"] key = "issues" if "milestone(number" in query else "milestones" out = responses[key].pop(0) if isinstance(responses[key], list) else responses[key] return 0, out, "" @@ -178,9 +180,8 @@ def commit(message: str) -> str: STUB = """#!/usr/bin/env python3 import json, sys -args = sys.argv[1:] data = json.load(open({path!r})) -query = next(a for a in args if a.startswith("query=")) +query = json.load(sys.stdin)["query"] key = "issues" if "milestone(number" in query else "milestones" sys.stdout.write(data[key]) """ @@ -223,9 +224,9 @@ def test_empty_token_is_permission_and_not_retried(monkeypatch): calls = [] inner = fetch_roadmap._runner("/nonexistent/gh") - def run(args): + def run(args, stdin=None): calls.append(args) - return inner(args) + return inner(args, stdin) result = fetch(run, "o/n", 240, sleep=lambda s: None, clock=lambda: 0.0) assert result["class"] == "permission" and len(calls) == 1 @@ -235,7 +236,7 @@ def run(args): import json, os, sys open({dump!r}, "w").write(json.dumps(sorted(os.environ))) data = json.load(open({path!r})) -query = next(a for a in sys.argv[1:] if a.startswith("query=")) +query = json.load(sys.stdin)["query"] sys.stdout.write(data["issues" if "milestone(number" in query else "milestones"]) """ @@ -256,7 +257,7 @@ def test_gh_environment_is_exactly_the_minimal_set(tmp_path): "--gh", str(stub)], capture_output=True, text=True, timeout=60, env=poisoned) # CPython (locale coercion) and macOS add these two themselves, whatever the caller passes. keys = set(json.loads(dump.read_text())) - {"__CF_USER_TEXT_ENCODING", "LC_CTYPE"} - assert keys == {"PATH", "HOME", "GH_TOKEN", "GH_HOST"} + assert keys == {"PATH", "HOME", "GH_TOKEN", "GH_HOST", "GH_PROMPT_DISABLED", "GH_NO_UPDATE_NOTIFIER"} def page(nodes, more, cursor): @@ -275,8 +276,8 @@ def test_issue_pages_carry_cursor_and_dedupe(): run, calls = fake_run({"milestones": http(200, MILESTONES), "issues": pages}) result = fetch(run, "o/n", 240, sleep=lambda s: None, clock=lambda: 0.0) assert result["status"] == "ok" and len(result["milestones"][0]["issues"]) == 1 - assert "cursor=c1" in calls[2] - assert "cursor=c1" not in calls[1] + assert calls[2]["variables"]["cursor"] == "c1" + assert "cursor" not in calls[1]["variables"] def test_max_pages_overflow_is_code_defect(monkeypatch): diff --git a/tests/test_gh_runner.py b/tests/test_gh_runner.py new file mode 100644 index 00000000..8658b917 --- /dev/null +++ b/tests/test_gh_runner.py @@ -0,0 +1,200 @@ +"""Tests for the shared gh runner. + +Every subprocess case runs a fake `gh`, a small script that records its argv, its stdin, +and its environment to files. The fake runs under the test interpreter rather than +/usr/bin/env python3, because the macOS python3 shim injects variables that would mask +what the runner passed. +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools")) + +import gh_runner # noqa: E402 +from gh_runner import RunnerError, make_runner, paginate, remaining, split_http # noqa: E402 + +REPO = gh_runner.REPO +TOKEN = "ghs_" + "A1b2C3d4E5" * 3 + "F6g7H8" +FREE_TEXT_FLAGS = ("-f", "-F", "--jq", "-q", "-t", "--template") + +# One valid argv for every shape the allowlist admits. +VALID = ( + ["api", "-i", f"repos/{REPO}/pulls/12/files?per_page=100"], + ["api", "-i", f"repos/{REPO}/issues"], + ["api", "--paginate", "--slurp", f"repos/{REPO}/issues/7/timeline?per_page=100"], + ["api", "-i", f"repos/{REPO}/actions/workflows/pr-intake.yml/runs?event=pull_request&per_page=5"], + ["api", "rate_limit"], + ["api", "-i", "graphql", "--input", "-"], + ["api", "-i", "-X", "PATCH", f"repos/{REPO}/issues/comments/991", "--input", "-"], + ["api", "-i", "-X", "POST", f"repos/{REPO}/issues/12/comments", "--input", "-"], + ["api", "-i", "-X", "POST", f"repos/{REPO}/issues/12/labels", "--input", "-"], + ["api", "-i", "-X", "DELETE", f"repos/{REPO}/issues/12/labels/status%3Aneeds-triage"], + ["pr", "merge", "12", "--disable-auto", "--repo", REPO], + ["pr", "ready", "12", "--undo", "--repo", REPO], +) + +FAKE = """#!{python} +import json, os, sys +record = {{"argv": sys.argv[1:], "stdin": sys.stdin.read(), "env": sorted(os.environ)}} +open({log!r}, "w").write(json.dumps(record)) +sys.stdout.write({stdout!r}) +sys.stderr.write({stderr!r}) +sys.exit({code}) +""" + + +def fake_gh(tmp_path: Path, stdout: str = "", stderr: str = "", code: int = 0) -> tuple[str, Path]: + log = tmp_path / "call.json" + script = tmp_path / "gh" + script.write_text(FAKE.format(python=sys.executable, log=str(log), stdout=stdout, stderr=stderr, code=code)) + script.chmod(0o755) + return str(script), log + + +def test_runner_refuses_argv_outside_the_allowlist(tmp_path): + gh, log = fake_gh(tmp_path, stdout="{}") + run = make_runner(gh, token=TOKEN) + for args in VALID: + assert run(list(args), None)[0] == 0 + refused = ( + [], + ["api"], + ["api", f"repos/{REPO}/issues"], + ["api", "-i", "repos/someone/else/issues"], + ["api", "-i", f"repos/{REPO}/issues/../../../user"], + ["api", "-i", f"repos/{REPO}/issues/12\n"], + ["api", "-i", f"repos/{REPO}/issues/1234567890"], + ["api", "-i", f"repos/{REPO}/issues?q=a b"], + ["api", "-i", "-X", "DELETE", f"repos/{REPO}/issues/12/labels/status%3Aaccepted"], + ["api", "-i", "-X", "PUT", f"repos/{REPO}/issues/12/labels", "--input", "-"], + ["api", "-i", "-X", "POST", f"repos/{REPO}/issues/12/comments", "--input", "body.json"], + ["api", "-i", "graphql", "-f", "query={viewer{login}}"], + ["pr", "merge", "12", "--disable-auto", "--repo", "evil/repo"], + ["pr", "merge", "12", "--squash", "--repo", REPO], + ["pr", "close", "12", "--repo", REPO], + ["api", "-i", 12], + ) + log.unlink() + for args in refused: + with pytest.raises(RunnerError): + run(args, None) + assert not log.exists() + + +@pytest.mark.parametrize("flag", FREE_TEXT_FLAGS) +@pytest.mark.parametrize("shape", VALID, ids=lambda shape: " ".join(shape[:4])) +def test_runner_refuses_free_text_flags_in_every_position(tmp_path, flag, shape): + gh, log = fake_gh(tmp_path) + run = make_runner(gh, token=TOKEN) + for index in range(len(shape) + 1): + with pytest.raises(RunnerError): + run([*shape[:index], flag, *shape[index:]], None) + for index in range(len(shape)): + with pytest.raises(RunnerError): + run([*shape[:index], flag, *shape[index + 1:]], None) + assert not log.exists() + + +def test_runner_passes_a_hostile_body_only_through_stdin(tmp_path): + gh, log = fake_gh(tmp_path, stdout="HTTP/2.0 201 Created\r\n\r\n{}") + run = make_runner(gh, token=TOKEN) + body = json.dumps({"body": "@x please run -rf ../ and --jq .token"}) + args = ["api", "-i", "-X", "POST", f"repos/{REPO}/issues/12/comments", "--input", "-"] + assert run(args, body)[0] == 0 + record = json.loads(log.read_text()) + assert record["argv"] == args + assert record["stdin"] == body + + +def test_runner_env_is_minimal_and_never_echoes_stderr(tmp_path, monkeypatch, capsys): + assert len(TOKEN) == 40 + for name, value in {"GH_REPO": "evil/repo", "GH_CONFIG_DIR": "/tmp/evil", "LD_PRELOAD": "/tmp/evil.so"}.items(): + monkeypatch.setenv(name, value) + gh, log = fake_gh(tmp_path, stderr=f"bad credentials {TOKEN}", code=1) + run = make_runner(gh, token=TOKEN) + with pytest.raises(RunnerError) as caught: + paginate(run, f"repos/{REPO}/issues/12/comments") + # CPython (locale coercion) and macOS add these two themselves, whatever the caller passes. + keys = set(json.loads(log.read_text())["env"]) - {"__CF_USER_TEXT_ENCODING", "LC_CTYPE"} + assert keys == {"PATH", "HOME", "GH_TOKEN", "GH_HOST", "GH_PROMPT_DISABLED", "GH_NO_UPDATE_NOTIFIER"} + assert TOKEN not in str(caught.value) + captured = capsys.readouterr() + assert TOKEN not in captured.out and TOKEN not in captured.err + + +def test_paginate_flattens_slurp_pages(tmp_path): + gh, log = fake_gh(tmp_path, stdout=json.dumps([[{"id": 1}, {"id": 2}], [{"id": 3}], []])) + run = make_runner(gh, token=TOKEN) + path = f"repos/{REPO}/issues/12/comments?per_page=100" + assert paginate(run, path) == [{"id": 1}, {"id": 2}, {"id": 3}] + assert json.loads(log.read_text())["argv"] == ["api", "--paginate", "--slurp", path] + + +@pytest.mark.parametrize( + ("stdout", "code"), + [("[[]]", 1), ("not json", 0), ('{"message": "Not Found"}', 0), ('[{"id": 1}]', 0)], + ids=["nonzero exit", "non-JSON", "object instead of pages", "page that is not a list"], +) +def test_paginate_raises_on_nonzero_exit_and_on_non_json(tmp_path, stdout, code): + gh, _log = fake_gh(tmp_path, stdout=stdout, code=code) + with pytest.raises(RunnerError): + paginate(make_runner(gh, token=TOKEN), f"repos/{REPO}/issues") + + +def test_runner_without_token_returns_synthetic_401(tmp_path, monkeypatch): + monkeypatch.delenv("GH_TOKEN", raising=False) + gh, log = fake_gh(tmp_path, stdout="HTTP/2.0 200 OK\r\n\r\n{}") + code, stdout, stderr = make_runner(gh)(["api", "-i", f"repos/{REPO}/issues"], None) + assert (code, stderr) == (0, "") + assert split_http(stdout)[0] == 401 + assert not log.exists() + + +def test_runner_reports_a_missing_binary_and_a_timeout_as_tuples(tmp_path, monkeypatch): + run = make_runner(str(tmp_path / "missing"), token=TOKEN) + assert run(["api", "rate_limit"], None)[0] == 127 + + def slow(*args, **kwargs): + raise gh_runner.subprocess.TimeoutExpired(args, kwargs["timeout"]) + + monkeypatch.setattr(gh_runner.subprocess, "run", slow) + assert run(["api", "rate_limit"], None) == (124, "", "gh timed out") + + +def test_graphql_shape_takes_its_body_on_stdin(tmp_path): + gh, log = fake_gh(tmp_path, stdout="HTTP/2.0 200 OK\r\n\r\n{}") + run = make_runner(gh, token=TOKEN) + body = json.dumps({"query": "query{viewer{login}}", "variables": {"owner": "o"}}) + run(["api", "-i", "graphql", "--input", "-"], body) + record = json.loads(log.read_text()) + assert record["argv"] == ["api", "-i", "graphql", "--input", "-"] + assert json.loads(record["stdin"]) == json.loads(body) + + +def test_split_http_reads_status_headers_and_body(): + status, headers, body = split_http("HTTP/2.0 403 Forbidden\r\nX-RateLimit-Remaining: 0\r\n\r\n{\"a\": 1}") + assert (status, headers, body) == (403, {"x-ratelimit-remaining": "0"}, '{"a": 1}') + with pytest.raises(RunnerError): + split_http("no status line") + + +@pytest.mark.parametrize( + ("stdout", "code", "expected"), + [ + (json.dumps({"resources": {"core": {"remaining": 4321}}}), 0, 4321), + (json.dumps({"resources": {"core": {"remaining": 4321}}}), 1, None), + (json.dumps({"resources": {"core": {"remaining": "4321"}}}), 0, None), + (json.dumps({"resources": {"core": {"remaining": True}}}), 0, None), + (json.dumps({"resources": []}), 0, None), + ("not json", 0, None), + ], +) +def test_remaining_reads_core_remaining_in_python(tmp_path, stdout, code, expected): + gh, log = fake_gh(tmp_path, stdout=stdout, code=code) + assert remaining(make_runner(gh, token=TOKEN)) == expected + assert json.loads(log.read_text())["argv"] == ["api", "rate_limit"] diff --git a/tests/test_roadmap_sync_health.py b/tests/test_roadmap_sync_health.py index 4221969a..fe1df45c 100644 --- a/tests/test_roadmap_sync_health.py +++ b/tests/test_roadmap_sync_health.py @@ -276,7 +276,7 @@ class FactsGitHub: def paginate(self, path): return [] - def call_list(self, args): + def call_list(self, args, stdin=None): import json as _json empty = {"data": {"repository": {"milestones": {"pageInfo": {"hasNextPage": False, "endCursor": None}, "nodes": []}}}} return 0, "HTTP/2.0 200 OK\r\n\r\n" + _json.dumps(empty), "" diff --git a/tools/fetch_roadmap.py b/tools/fetch_roadmap.py index 6c6a38b6..913ce1e1 100755 --- a/tools/fetch_roadmap.py +++ b/tools/fetch_roadmap.py @@ -7,9 +7,10 @@ This runs as the first tool step of the deploy build job, before any package is installed, under `python3 -I -S`. It is standard library only. `-I` drops the script's own directory from the import path, so `main` adds tools/ back inside its `try` and imports only -closing_choice, which is standard library only too. An import failure therefore still -writes the failure record. It calls `gh` and `git` by absolute path with explicit minimal -environments, so nothing an earlier step wrote into the environment can redirect them. +closing_choice there, plus gh_runner and roadmap_model through `_runner`. All three are +standard library only too. An import failure therefore still writes the failure record. It +calls `gh` and `git` by absolute path with explicit minimal environments, so nothing an +earlier step wrote into the environment can redirect them. Every closing message is parsed for two booleans and then discarded. No message, title, or body text reaches the output file. @@ -131,13 +132,9 @@ def classify_response(returncode: int, stdout: str, stderr: str) -> dict: def _graphql(run, query: str, variables: dict) -> dict: - args = ["api", "-i", "graphql", "-f", f"query={query}"] - for key, value in variables.items(): - if value is None: - continue - flag = "-F" if isinstance(value, int) else "-f" - args += [flag, f"{key}={value}"] - return classify_response(*run(args)) + # The request travels on stdin as JSON, so no query text or variable reaches gh argv. + body = json.dumps({"query": query, "variables": {k: v for k, v in variables.items() if v is not None}}) + return classify_response(*run(["api", "-i", "graphql", "--input", "-"], body)) def _same_repo(block: dict | None, repo: str) -> bool: @@ -401,28 +398,11 @@ def check(pair: list) -> str: def _runner(gh: str): - token = os.environ.get("GH_TOKEN", "") - env = { - "PATH": "/usr/bin:/bin", - "HOME": tempfile.mkdtemp(prefix="roadmap-gh-"), - "GH_TOKEN": token, - "GH_HOST": "github.com", - } - - def run(args: list[str]) -> tuple[int, str, str]: - if not token: - # Without a token gh would prompt or fall back to ambient credentials. A synthetic 401 - # classifies as permission, which is never retried. - return 0, "HTTP/2.0 401 Unauthorized\r\n\r\n{}", "" - try: - done = subprocess.run([gh, *args], env=env, capture_output=True, text=True, timeout=60) - except subprocess.TimeoutExpired: - return 124, "", "gh timed out" - except OSError as exc: - return 127, "", f"could not run {gh}: {exc.strerror}" - return done.returncode, done.stdout, done.stderr + # Imported here, not at module level, because main adds tools/ to the import path only + # inside its try. An import failure there still writes the failure record. + import gh_runner - return run + return gh_runner.make_runner(gh) def main(argv: list[str] | None = None) -> int: diff --git a/tools/gh_runner.py b/tools/gh_runner.py new file mode 100644 index 00000000..6000e3af --- /dev/null +++ b/tools/gh_runner.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Run `gh` for the repository's automation, with every argv checked against an allowlist. + +Version 1.0. Owner: ACS project leads. Spec: design/2026-10-29-pr-intake-rules-design.md. + +The pull request intake tool, the roadmap health renderer, and the roadmap monitor share +this runner. They handle text a pull request author controls: titles, bodies, paths, and +logins. The property this module exists for is that none of that text ever reaches `gh` +argv, a `--jq` program, or a shell. Request bodies travel on stdin as JSON, and every argv +must match one fixed shape below, element by element, before any process starts. + +No shape admits a free-text flag (`-f`, `-F`, `--jq`, `-q`, `-t`, `--template`) in any +position, because each element is a literal or a pattern that cannot match one. Paths are +anchored to this repository, so a caller cannot reach another repository or the user API. +Every pattern is linear, with no nested quantifiers. + +The process itself gets an absolute `gh`, an environment of six keys, and a 60 second +timeout. A timeout or a missing binary returns a failure tuple instead of raising, so the +caller classifies it like any other failed call. Error messages raised here carry fixed +text only, never gh's stderr, which can echo a token. + +It runs under `python3 -I -S` and is standard library only. +""" +from __future__ import annotations + +import json +import os +import re +import subprocess +import tempfile +from collections.abc import Callable + +from roadmap_model import REPO + +GH = "/usr/bin/gh" +TIMEOUT_SECONDS = 60 + +Runner = Callable[[list[str], "str | None"], tuple[int, str, str]] + + +class RunnerError(RuntimeError): + """An argv outside the allowlist, or a response a helper here cannot read.""" + + +_REPO = re.escape(REPO) +PATH = re.compile( + rf"^repos/{_REPO}/(pulls|issues)(/[0-9]{{1,9}})?(/(files|reviews|comments|timeline|labels))?(\?[A-Za-z0-9_=&%.-]*)?$" +) +RUNS_PATH = re.compile(rf"^repos/{_REPO}/actions/workflows/pr-intake\.yml/runs\?[A-Za-z0-9_=&]*$") +COMMENT_PATH = re.compile(rf"^repos/{_REPO}/issues/comments/[0-9]{{1,9}}$") +COMMENTS_PATH = re.compile(rf"^repos/{_REPO}/issues/[0-9]{{1,9}}/comments$") +LABELS_PATH = re.compile(rf"^repos/{_REPO}/issues/[0-9]{{1,9}}/labels$") +LABEL_PATH = re.compile(rf"^repos/{_REPO}/issues/[0-9]{{1,9}}/labels/status%3Aneeds-triage$") +DIGITS = re.compile(r"^[0-9]{1,9}$") + +# Every write carries -i so the status line and headers come back on stdout, which lets +# the caller tell a rejected write from a successful one without parsing stderr. +ALLOWLIST: tuple[tuple[str | re.Pattern[str], ...], ...] = ( + ("api", "-i", PATH), + ("api", "--paginate", "--slurp", PATH), + ("api", "-i", RUNS_PATH), + ("api", "rate_limit"), + ("api", "-i", "graphql", "--input", "-"), + ("api", "-i", "-X", "PATCH", COMMENT_PATH, "--input", "-"), + ("api", "-i", "-X", "POST", COMMENTS_PATH, "--input", "-"), + ("api", "-i", "-X", "POST", LABELS_PATH, "--input", "-"), + ("api", "-i", "-X", "DELETE", LABEL_PATH), + ("pr", "merge", DIGITS, "--disable-auto", "--repo", REPO), + ("pr", "ready", DIGITS, "--undo", "--repo", REPO), +) + + +def _matches(shape: tuple[str | re.Pattern[str], ...], args: list[str]) -> bool: + if len(shape) != len(args): + return False + for expected, actual in zip(shape, args): + if not isinstance(actual, str): + return False + if isinstance(expected, str) and actual != expected: + return False + # fullmatch, so the `$` in a pattern cannot accept a trailing newline. + if isinstance(expected, re.Pattern) and not expected.fullmatch(actual): + return False + return True + + +def _check_allowlist(args: list[str]) -> None: + if not isinstance(args, list) or not any(_matches(shape, args) for shape in ALLOWLIST): + raise RunnerError("gh argv is outside the allowlist") + + +def make_runner(gh: str = GH, token: str | None = None) -> Runner: + """Build a runner for one process lifetime. The token defaults to GH_TOKEN.""" + if token is None: + token = os.environ.get("GH_TOKEN", "") + # A fresh HOME and nothing inherited, so no earlier step can point gh at another host, + # config directory, or repository, or preload code into it. + env = { + "PATH": "/usr/bin:/bin", + "HOME": tempfile.mkdtemp(prefix="acs-gh-"), + "GH_TOKEN": token, + "GH_HOST": "github.com", + "GH_PROMPT_DISABLED": "1", + "GH_NO_UPDATE_NOTIFIER": "1", + } + + def run(args: list[str], stdin: str | None = None) -> tuple[int, str, str]: + _check_allowlist(args) + if not token: + # Without a token gh would prompt or fall back to ambient credentials. A synthetic + # 401 classifies as a permission failure, which no caller retries. + return 0, "HTTP/2.0 401 Unauthorized\r\n\r\n{}", "" + try: + done = subprocess.run( + [gh, *args], input=stdin, env=env, capture_output=True, text=True, timeout=TIMEOUT_SECONDS + ) + except subprocess.TimeoutExpired: + return 124, "", "gh timed out" + except OSError as exc: + return 127, "", f"could not run {gh}: {exc.strerror}" + return done.returncode, done.stdout, done.stderr + + return run + + +def paginate(run: Runner, path: str) -> list: + """Every item from every page of a list endpoint, flattened into one list.""" + code, stdout, _stderr = run(["api", "--paginate", "--slurp", path], None) + if code != 0: + raise RunnerError(f"gh api --paginate exited {code}") + try: + pages = json.loads(stdout) + except ValueError: + raise RunnerError("gh api --paginate returned non-JSON output") from None + if not isinstance(pages, list) or not all(isinstance(page, list) for page in pages): + raise RunnerError("gh api --paginate returned a page that is not a list") + return [item for page in pages for item in page] + + +def split_http(stdout: str) -> tuple[int, dict[str, str], str]: + """Split `gh api -i` output into its status, lowercased headers, and body.""" + normalized = stdout.replace("\r\n", "\n") + head, _, body = normalized.partition("\n\n") + lines = head.split("\n") + try: + status = int(lines[0].split()[1]) + except (IndexError, ValueError): + raise RunnerError("response had no HTTP status line") from None + headers = {} + for line in lines[1:]: + name, _, value = line.partition(":") + headers[name.strip().lower()] = value.strip() + return status, headers, body + + +def remaining(run: Runner) -> int | None: + """The core rate limit budget left, or None when it cannot be read. + + The field is read here rather than with --jq, because no shape admits a jq program. + """ + code, stdout, _stderr = run(["api", "rate_limit"], None) + if code != 0: + return None + try: + value = json.loads(stdout)["resources"]["core"]["remaining"] + except (ValueError, KeyError, TypeError): + return None + # bool is an int subclass, and True is not a budget. + if isinstance(value, bool) or not isinstance(value, int): + return None + return value diff --git a/tools/roadmap_sync.py b/tools/roadmap_sync.py index 7b685701..b7b73428 100644 --- a/tools/roadmap_sync.py +++ b/tools/roadmap_sync.py @@ -81,18 +81,20 @@ class GitHub: def __init__(self, repo: str = model.REPO) -> None: self.repo = repo - def call(self, *args: str) -> tuple[int, str, str]: + def call(self, *args: str, stdin: str | None = None) -> tuple[int, str, str]: # Thirty seconds per call keeps the sweep inside its fifteen-minute job limit. A # timeout reads as a failed call, so the section that made it degrades. try: - done = subprocess.run(["gh", *args], capture_output=True, text=True, timeout=GH_TIMEOUT_SECONDS) + done = subprocess.run( + ["gh", *args], input=stdin, capture_output=True, text=True, timeout=GH_TIMEOUT_SECONDS + ) except subprocess.TimeoutExpired: return 124, "", f"gh timed out after {GH_TIMEOUT_SECONDS} seconds" return done.returncode, done.stdout, done.stderr - def call_list(self, args: list[str]) -> tuple[int, str, str]: + def call_list(self, args: list[str], stdin: str | None = None) -> tuple[int, str, str]: """The runner signature fetch_roadmap.fetch expects.""" - return self.call(*args) + return self.call(*args, stdin=stdin) def _check(self, result: tuple[int, str, str], what: str) -> str: code, out, err = result From d9e22ef64b66cf521eaea75bce42657ec781e34a Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 19:54:21 -0600 Subject: [PATCH 02/31] Parse gh -i output with the shared runner's parser in the roadmap fetch Signed-off-by: rocklambros --- tests/test_fetch_roadmap.py | 7 +++++++ tools/fetch_roadmap.py | 17 +++++++---------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/tests/test_fetch_roadmap.py b/tests/test_fetch_roadmap.py index 238934f7..07e42a20 100644 --- a/tests/test_fetch_roadmap.py +++ b/tests/test_fetch_roadmap.py @@ -117,6 +117,13 @@ def test_empty_output_is_transport(): assert caught.value.cls == "transport" +def test_output_without_a_status_line_is_data(): + # The parser lives in gh_runner. Its error must still reach the record as a fetch class. + with pytest.raises(FetchFailure) as caught: + classify_response(0, "not an HTTP response", "") + assert (caught.value.cls, caught.value.detail) == ("data", "response had no HTTP status line") + + def test_permission_is_not_retried(): run, calls = fake_run({"milestones": http(403, {"message": "nope"}), "issues": ""}) result = fetch(run, "o/n", 240, sleep=lambda s: None, clock=lambda: 0.0) diff --git a/tools/fetch_roadmap.py b/tools/fetch_roadmap.py index 913ce1e1..55a684da 100755 --- a/tools/fetch_roadmap.py +++ b/tools/fetch_roadmap.py @@ -83,18 +83,15 @@ def __init__(self, cls: str, detail: str) -> None: def _split_http(stdout: str) -> tuple[int, dict[str, str], str]: - normalized = stdout.replace("\r\n", "\n") - head, _, body = normalized.partition("\n\n") - lines = head.split("\n") + # One parser for every `gh -i` caller, so the fetch and the intake tool cannot drift + # apart. Imported here for the same reason `_runner` imports it: tools/ joins the import + # path only inside main's try. + import gh_runner + try: - status = int(lines[0].split()[1]) - except (IndexError, ValueError): + return gh_runner.split_http(stdout) + except gh_runner.RunnerError: raise FetchFailure("data", "response had no HTTP status line") from None - headers = {} - for line in lines[1:]: - name, _, value = line.partition(":") - headers[name.strip().lower()] = value.strip() - return status, headers, body def classify_response(returncode: int, stdout: str, stderr: str) -> dict: From 79648f7b08bdbaacaf372a846245ea3c1193a9f2 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 19:56:57 -0600 Subject: [PATCH 03/31] Add the intake tool's facts, decision, and data line Signed-off-by: rocklambros --- tests/test_pr_intake.py | 111 +++++++++++++++++++ tools/pr_intake.py | 236 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 347 insertions(+) create mode 100644 tests/test_pr_intake.py create mode 100644 tools/pr_intake.py diff --git a/tests/test_pr_intake.py b/tests/test_pr_intake.py new file mode 100644 index 00000000..675af9c3 --- /dev/null +++ b/tests/test_pr_intake.py @@ -0,0 +1,111 @@ +"""Tests for the intake tool's data model: the facts hash, the data line, and the helpers.""" +from __future__ import annotations + +import dataclasses +import sys +from datetime import date, datetime, timedelta, timezone +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools")) + +import closing_choice # noqa: E402 +import pr_intake # noqa: E402 +from pr_intake import Facts, Reference, Review, facts_hash, parse_data_line, render_data_line, safe_path # noqa: E402 + +TODAY = date(2026, 11, 10) +NOW = datetime(2026, 11, 1, tzinfo=timezone.utc) +TEMPLATE = Path(__file__).resolve().parents[1] / ".github" / "pull_request_template.md" + + +def make_facts(**overrides) -> Facts: + base = dict( + number=7, state="open", draft=False, author="alice", author_is_bot=False, title="t", body="b", + base="main", head_sha="a" * 40, created_at=NOW, updated_at=NOW, labels=frozenset(), + paths=("docs/a.md",), paths_truncated=False, references=(), references_dropped=0, reviews=(), + last_unlabel=None, last_author_comment_at=None, bot_comment=None, auto_merge=False, + author_record=False, held_use={}, roster=frozenset({"bob"}), mode="enforced", sweep=False, + event_action=None, + ) + base.update(overrides) + return Facts(**base) + + +def reference(number: int = 5) -> Reference: + return Reference( + number=number, kind="issue", is_pull=False, state="open", labels=frozenset({"accepted"}), + assignees=frozenset(), milestoned=False, author_commented_at=None, unresolved=False, + ) + + +def test_data_line_round_trips(): + fields = { + "reason": "no_accepted,held_cap", "route": "roster", "issues": "5,6", "since": "2026-11-01", + "cleared": "2026-11-02", "head": "a" * 12, "cleared_head": "b" * 12, "facts": "0123456789ab", + "claimed": "5:2026-11-01,6:2026-11-02", "seen": "2026-11-01T00:00:00Z,2026-11-02T00:00:00Z", + } + line = render_data_line(fields) + assert line.startswith(pr_intake.DATA_PREFIX) and line.endswith("-->") and "\n" not in line + assert parse_data_line(line) == fields + assert parse_data_line(line + "\n" + pr_intake.MARKER) == fields + + +def test_parse_data_line_reads_line_one_only(): + body = "hello\n" + pr_intake.MARKER + "\n" + assert parse_data_line(body) is None + assert parse_data_line("") is None + + +@pytest.mark.parametrize( + "line", + [ + "", + "", + "", + "", + "", + " trailing", + ], +) +def test_data_line_rejects_bad_shapes(line): + assert parse_data_line(line) is None + + +def test_render_data_line_refuses_what_it_could_not_read_back(): + with pytest.raises(ValueError): + render_data_line({"reason": "a b"}) + with pytest.raises(ValueError): + render_data_line({"bogus": "1"}) + + +def test_facts_hash_changes_with_references_and_with_a_review_crossing_thirty_days_not_with_updated_at(): + base = make_facts() + digest = facts_hash(base, TODAY) + assert len(digest) == 12 and int(digest, 16) >= 0 + assert facts_hash(dataclasses.replace(base, updated_at=NOW + timedelta(days=1)), TODAY) == digest + assert facts_hash(dataclasses.replace(base, references=(reference(),)), TODAY) != digest + + submitted = datetime(2026, 10, 15, tzinfo=timezone.utc) + review = Review(login="bob", state="CHANGES_REQUESTED", association="MEMBER", submitted_at=submitted, commit_id="c" * 40) + reviewed = dataclasses.replace(base, reviews=(review,)) + fresh = facts_hash(reviewed, submitted.date() + timedelta(days=30)) + stale = facts_hash(reviewed, submitted.date() + timedelta(days=31)) + assert fresh != stale + + +def test_editorial_ticked_agrees_with_closing_choice_on_the_template_line(): + line = next(l for l in TEMPLATE.read_text().splitlines() if "This is an editorial correction" in l) + ticked = line.replace("[ ]", "[x]", 1) + assert pr_intake.editorial_ticked(ticked) is True + assert pr_intake.editorial_ticked(line) is False + for text in (ticked, line, "**" + ticked + "**"): + assert pr_intake.editorial_ticked(text) == bool(closing_choice.EDITORIAL.search(closing_choice.normalize(text))) + + +def test_safe_path_replaces_backtick_marker_and_at(): + cleaned = safe_path("a`b@c d.md") + assert not any(char in cleaned for char in "`<>@! ") + assert cleaned.startswith("a?b?") and cleaned.endswith("?c?d.md") + assert safe_path("docs/ok-1_2.md") == "docs/ok-1_2.md" + assert len(safe_path("a" * 500)) == 200 diff --git a/tools/pr_intake.py b/tools/pr_intake.py new file mode 100644 index 00000000..474c58c5 --- /dev/null +++ b/tools/pr_intake.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Decide whether a pull request has an accepted issue behind it, and say so once. + +Version 1.0. Owner: ACS project leads. This module holds the data model and the +parsing helpers. The gate logic, the collectors, and the writers join it in later +tasks of the same plan. + +The workflow runs this under `python3 -I -S` from a checkout of `main`, never the pull +request's head, with the standard library and the sibling modules under `tools/` only. +Everything read from GitHub is untrusted text: titles, bodies, comments, and file paths +are attacker-controlled. Two rules follow. Free text never reaches a command line or a +log line unescaped, which is why `safe_path` exists. And the bot's own comment carries +state on its first line, the data line, which is parsed with a strict pattern so a +hostile comment cannot forge state. Every pattern here is linear, with bounded repeats +and no nested quantifiers, so a hostile body cannot stall the job. +""" +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass +from datetime import date, datetime, timedelta + +import closing_choice + +# The marker sits on line 2 of the bot comment. The data line sits on line 1, ahead of it. +MARKER = "" +DATA_PREFIX = "$") +_PAIR = re.compile(r"([a-z_]{1,16})=([A-Za-z0-9,._:-]{1,200})") +_UNSAFE_PATH = re.compile(r"[^A-Za-z0-9._/-]") + +FRESH_REVIEW_DAYS = 30 + + +@dataclass(frozen=True) +class Reference: + """An issue or pull request that this pull request names, with the facts the gate reads.""" + + number: int + kind: str + is_pull: bool + state: str | None + labels: frozenset[str] + assignees: frozenset[str] + milestoned: bool + author_commented_at: datetime | None + unresolved: bool + + +@dataclass(frozen=True) +class Review: + login: str + state: str + association: str + submitted_at: datetime | None + commit_id: str | None + + +@dataclass(frozen=True) +class BotComment: + """The bot's existing comment on the pull request, parsed once at collection time.""" + + id: int + body: str + data: dict[str, str] | None + legacy: bool + + +@dataclass(frozen=True) +class Facts: + """Everything the gate reads about one pull request, gathered before any decision.""" + + number: int + state: str + draft: bool + author: str # casefolded + author_is_bot: bool + title: str + body: str + base: str + head_sha: str + created_at: datetime + updated_at: datetime + labels: frozenset[str] + paths: tuple[str, ...] + paths_truncated: bool + references: tuple[Reference, ...] + references_dropped: int + reviews: tuple[Review, ...] + last_unlabel: tuple[str, datetime] | None # actor casefolded + last_author_comment_at: datetime | None + bot_comment: BotComment | None + auto_merge: bool + author_record: bool + # The author's open pull requests, this one included, that reference each open accepted + # issue, sorted ascending. + held_use: dict[int, tuple[int, ...]] + roster: frozenset[str] + mode: str # "legacy" or "enforced" + sweep: bool + event_action: str | None + + +@dataclass(frozen=True) +class Decision: + outcome: str # "pass", "queue", or "skip" + route: str | None + reasons: tuple[str, ...] + label: str # "set", "clear", or "keep" + comment: str | None + data: dict[str, str] | None + disable_auto_merge: bool + notices: tuple[str, ...] + + +def parse_data_line(body: str) -> dict[str, str] | None: + """Read the bot's state from line 1 of a comment body, or None if it is not ours. + + Only line 1 counts. A well-formed line anywhere else is user text and carries no + authority, so a forged `since=` further down cannot move a clock. + """ + first = (body or "").split("\n", 1)[0].rstrip("\r") + match = _DATA_LINE.match(first) + if match is None: + return None + fields: dict[str, str] = {} + for key, value in _PAIR.findall(match.group(1)): + if key not in DATA_KEYS or key in fields: + return None + fields[key] = value + return fields + + +def render_data_line(fields: dict[str, str]) -> str: + """The data line for `fields`. Refuses anything `parse_data_line` could not read back.""" + line = DATA_PREFIX + " ".join(f"{key}={value}" for key, value in fields.items()) + "-->" + if parse_data_line(line) != fields: + raise ValueError("fields do not survive a round trip through the data line") + return line + + +def editorial_ticked(body: str) -> bool: + """Whether the editorial box is ticked, read exactly as the closing-choice check reads it.""" + return bool(closing_choice.EDITORIAL.search(closing_choice.normalize(body))) + + +def engaged_by_fresh_review(facts: Facts, today: date) -> bool: + """Whether a CHANGES_REQUESTED review is recent enough to keep the pull request engaged.""" + cutoff = today - timedelta(days=FRESH_REVIEW_DAYS) + return any( + review.state == "CHANGES_REQUESTED" + and review.submitted_at is not None + and review.submitted_at.date() >= cutoff + for review in facts.reviews + ) + + +def facts_hash(facts: Facts, today: date) -> str: + """Twelve hex characters that change when anything the gate reads changes. + + `updated_at` is left out because any comment moves it, which would make the hash + change on the bot's own writes. Review timestamps are left out for the same reason, + and their one effect, freshness, enters through the derived boolean so a review that + ages past thirty days still changes the hash. Title and body enter only through + `editorial_ticked`, the one thing the gate reads from them. + """ + parts = { + "number": facts.number, + "state": facts.state, + "draft": facts.draft, + "author": facts.author, + "author_is_bot": facts.author_is_bot, + "base": facts.base, + "head_sha": facts.head_sha, + "labels": sorted(facts.labels), + "paths": list(facts.paths), + "paths_truncated": facts.paths_truncated, + "references": sorted( + ( + ref.number, ref.kind, ref.is_pull, ref.state, sorted(ref.labels), sorted(ref.assignees), + ref.milestoned, ref.author_commented_at, ref.unresolved, + ) + for ref in facts.references + ), + "references_dropped": facts.references_dropped, + "reviews": sorted((r.login, r.state, r.association, r.commit_id) for r in facts.reviews), + "last_unlabel": facts.last_unlabel, + "last_author_comment_at": facts.last_author_comment_at, + "auto_merge": facts.auto_merge, + "author_record": facts.author_record, + "held_use": sorted(facts.held_use.items()), + "roster": sorted(facts.roster), + "mode": facts.mode, + "engaged_by_fresh_review": engaged_by_fresh_review(facts, today), + "editorial_ticked": editorial_ticked(facts.body), + } + return hashlib.sha256(repr(sorted(parts.items())).encode("utf-8")).hexdigest()[:12] + + +def safe_path(path: str) -> str: + """A changed file's path made safe to print. Backticks, markup, and mentions become `?`.""" + return _UNSAFE_PATH.sub("?", path)[:200] From 87b63faf3a7585fcaa5425fb2be3630d47f07e48 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 19:58:53 -0600 Subject: [PATCH 04/31] Hash the cleared head and make the facts hash sort safe Signed-off-by: rocklambros --- tests/test_pr_intake.py | 32 ++++++++++++++++++++++++++++++++ tools/pr_intake.py | 34 ++++++++++++++++++++++++---------- 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/tests/test_pr_intake.py b/tests/test_pr_intake.py index 675af9c3..baac35db 100644 --- a/tests/test_pr_intake.py +++ b/tests/test_pr_intake.py @@ -109,3 +109,35 @@ def test_safe_path_replaces_backtick_marker_and_at(): assert cleaned.startswith("a?b?") and cleaned.endswith("?c?d.md") assert safe_path("docs/ok-1_2.md") == "docs/ok-1_2.md" assert len(safe_path("a" * 500)) == 200 + + +def test_facts_hash_changes_with_cleared_head_and_with_reference_kind(): + base = make_facts() + digest = facts_hash(base, TODAY) + + def with_cleared(value): + comment = pr_intake.BotComment(id=1, body="", data={"cleared_head": value}, legacy=False) + return facts_hash(dataclasses.replace(base, bot_comment=comment), TODAY) + + assert with_cleared("a" * 12) != digest + assert with_cleared("a" * 12) != with_cleared("b" * 12) + + closing = dataclasses.replace(reference(), kind="close") + mention = dataclasses.replace(reference(), kind="mention") + assert facts_hash(dataclasses.replace(base, references=(closing,)), TODAY) != facts_hash( + dataclasses.replace(base, references=(mention,)), TODAY + ) + + +def test_facts_hash_survives_none_beside_a_value_in_sort_ties(): + base = make_facts() + reviews = tuple( + Review(login="bob", state="APPROVED", association="MEMBER", submitted_at=None, commit_id=commit) + for commit in (None, "c" * 40) + ) + refs = tuple( + dataclasses.replace(reference(), author_commented_at=when) for when in (None, NOW) + ) + forward = dataclasses.replace(base, reviews=reviews, references=refs) + backward = dataclasses.replace(base, reviews=reviews[::-1], references=refs[::-1]) + assert facts_hash(forward, TODAY) == facts_hash(backward, TODAY) diff --git a/tools/pr_intake.py b/tools/pr_intake.py index 474c58c5..d92c2a0d 100644 --- a/tools/pr_intake.py +++ b/tools/pr_intake.py @@ -192,11 +192,20 @@ def engaged_by_fresh_review(facts: Facts, today: date) -> bool: def facts_hash(facts: Facts, today: date) -> str: """Twelve hex characters that change when anything the gate reads changes. - `updated_at` is left out because any comment moves it, which would make the hash - change on the bot's own writes. Review timestamps are left out for the same reason, - and their one effect, freshness, enters through the derived boolean so a review that - ages past thirty days still changes the hash. Title and body enter only through - `editorial_ticked`, the one thing the gate reads from them. + Included: number, state, draft, author, author_is_bot, base, head_sha, labels, paths, + paths_truncated, references_dropped, reviews (login, state, association, commit_id), + last_unlabel, last_author_comment_at, auto_merge, author_record, held_use, roster, + mode, and every reference in full. The closing set enters through `references[].kind`, + which `closing_choice.parse_references` sets from the keyword, so `Closes #5` becoming + `Refs #5` changes the hash. `cleared_head` from the bot comment's data line enters + too, because the gate compares it with `head_sha`. Two derived booleans enter as well: + `engaged_by_fresh_review`, which carries review freshness, and `editorial_ticked`. + + Excluded: `updated_at` and `created_at`, because any comment moves `updated_at` and + the bot's own writes would change the hash. Raw review timestamps, whose one effect is + freshness, already carried by the derived boolean. `sweep` and `event_action`, which + describe why the run started, not the pull request. `title` and `body`, which enter + only through `editorial_ticked`, so an edit that changes no fact keeps the comment. """ parts = { "number": facts.number, @@ -209,15 +218,20 @@ def facts_hash(facts: Facts, today: date) -> str: "labels": sorted(facts.labels), "paths": list(facts.paths), "paths_truncated": facts.paths_truncated, + # Sorted by repr so a None beside a datetime or a string never raises TypeError. "references": sorted( ( - ref.number, ref.kind, ref.is_pull, ref.state, sorted(ref.labels), sorted(ref.assignees), - ref.milestoned, ref.author_commented_at, ref.unresolved, - ) - for ref in facts.references + ( + ref.number, ref.kind, ref.is_pull, ref.state, sorted(ref.labels), sorted(ref.assignees), + ref.milestoned, ref.author_commented_at, ref.unresolved, + ) + for ref in facts.references + ), + key=repr, ), "references_dropped": facts.references_dropped, - "reviews": sorted((r.login, r.state, r.association, r.commit_id) for r in facts.reviews), + "reviews": sorted(((r.login, r.state, r.association, r.commit_id) for r in facts.reviews), key=repr), + "cleared_head": (facts.bot_comment.data or {}).get("cleared_head") if facts.bot_comment else None, "last_unlabel": facts.last_unlabel, "last_author_comment_at": facts.last_author_comment_at, "auto_merge": facts.auto_merge, From 83fcdd46439ce2c445af3e4ecdb892bfbf3abe9b Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 20:09:12 -0600 Subject: [PATCH 05/31] Decide the intake gate from facts a lead performed Signed-off-by: rocklambros --- tests/test_pr_intake.py | 514 +++++++++++++++++++++++++++++++++++++++- tools/pr_intake.py | 509 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 1015 insertions(+), 8 deletions(-) diff --git a/tests/test_pr_intake.py b/tests/test_pr_intake.py index baac35db..2171d10a 100644 --- a/tests/test_pr_intake.py +++ b/tests/test_pr_intake.py @@ -1,7 +1,8 @@ -"""Tests for the intake tool's data model: the facts hash, the data line, and the helpers.""" +"""Tests for the intake tool: the data model, the data line, the helpers, and the gate.""" from __future__ import annotations import dataclasses +import json import sys from datetime import date, datetime, timedelta, timezone from pathlib import Path @@ -12,7 +13,11 @@ import closing_choice # noqa: E402 import pr_intake # noqa: E402 -from pr_intake import Facts, Reference, Review, facts_hash, parse_data_line, render_data_line, safe_path # noqa: E402 +import roadmap_model # noqa: E402 +from pr_intake import ( # noqa: E402 + BotComment, Facts, Reference, Review, decide, facts_hash, parse_data_line, render_comment, render_data_line, + safe_path, unmet_routes, +) TODAY = date(2026, 11, 10) NOW = datetime(2026, 11, 1, tzinfo=timezone.utc) @@ -141,3 +146,508 @@ def test_facts_hash_survives_none_beside_a_value_in_sort_ties(): forward = dataclasses.replace(base, reviews=reviews, references=refs) backward = dataclasses.replace(base, reviews=reviews[::-1], references=refs[::-1]) assert facts_hash(forward, TODAY) == facts_hash(backward, TODAY) + + +# --- The gate ------------------------------------------------------------------------- + +ROSTER = frozenset({"rocklambros", "lrd0036"}) +OPENED = datetime(2026, 11, 2, 12, tzinfo=timezone.utc) +HEAD = "a" * 40 +TICKED = "- [x] This is an editorial correction on the documentation lane (typo, grammar, link, formatting)" +NEEDS_TRIAGE = roadmap_model.NEEDS_TRIAGE +POPULATION = Path(__file__).resolve().parent / "fixtures" / "pr_intake" / "population.json" + + +def facts(**overrides) -> Facts: + """A non-roster pull request with no references, in enforced mode, opened after the rule.""" + base = dict( + number=354, state="open", draft=False, author="outsider", author_is_bot=False, title="Add a thing", + body="b", base="integration", head_sha=HEAD, created_at=OPENED, updated_at=OPENED, + labels=frozenset(), paths=("tools/x.py",), paths_truncated=False, references=(), references_dropped=0, + reviews=(), last_unlabel=None, last_author_comment_at=None, bot_comment=None, auto_merge=False, + author_record=False, held_use={}, roster=ROSTER, mode="enforced", sweep=False, event_action="opened", + ) + base.update(overrides) + return Facts(**base) + + +def issue(number, *, state="open", kind="contribute", labels=(roadmap_model.ACCEPTED,), assignees=(), + milestoned=False, commented=None, is_pull=False, unresolved=False) -> Reference: + return Reference( + number=number, kind=kind, is_pull=is_pull, state=state, labels=frozenset(labels), + assignees=frozenset(assignees), milestoned=milestoned, author_commented_at=commented, unresolved=unresolved, + ) + + +def review(*, login="rocklambros", state="APPROVED", association="MEMBER", days=1, commit=HEAD, today=TODAY) -> Review: + submitted = datetime.combine(today - timedelta(days=days), datetime.min.time(), tzinfo=timezone.utc) + return Review(login=login, state=state, association=association, submitted_at=submitted, commit_id=commit) + + +def with_comment(f: Facts, decision) -> Facts: + """The facts as the next run reads them, after the writer posted `decision`'s comment.""" + body = decision.comment + return dataclasses.replace(f, bot_comment=BotComment(id=1, body=body, data=parse_data_line(body), legacy=False)) + + +def outcome(decision): + return decision.outcome, decision.route, decision.reasons + + +def test_known_bot_author_is_out_of_scope(): + for author in sorted(pr_intake.KNOWN_BOTS): + for mode in ("enforced", "legacy"): + decision = decide(facts(author=author, author_is_bot=True, mode=mode), TODAY) + assert decision.outcome == "skip" and decision.label == "keep" + assert decision.comment is None and decision.data is None and decision.disable_auto_merge is False + + +def test_unknown_bot_author_is_evaluated_as_non_roster(): + decision = decide(facts(author="renovate[bot]", author_is_bot=True), TODAY) + assert outcome(decision) == ("queue", None, ("no_accepted",)) + assert decision.label == "set" and decision.comment is not None + + +def test_draft_is_skipped(): + for mode in ("enforced", "legacy"): + decision = decide(facts(draft=True, mode=mode), TODAY) + assert decision.outcome == "skip" and decision.label == "keep" and decision.comment is None + + +def test_roster_author_passes_case_insensitively(): + stored = facts(author="Lrd0036".casefold()) + assert outcome(decide(stored, TODAY)) == ("pass", "roster", ()) + assert outcome(decide(facts(author="Lrd0036"), TODAY)) == ("pass", "roster", ()) + # Never queued and never labeled: nothing to write. + quiet = decide(stored, TODAY) + assert quiet.label == "keep" and quiet.comment is None + labeled = decide(dataclasses.replace(stored, labels=frozenset({NEEDS_TRIAGE})), TODAY) + assert labeled.label == "clear" and labeled.data["reason"] == "cleared" and labeled.data["route"] == "roster" + + +def test_approved_review_by_roster_collaborator_passes(): + decision = decide(facts(reviews=(review(login="LRD0036".casefold(), association="COLLABORATOR", days=400),)), TODAY) + assert outcome(decision) == ("pass", "review", ()) + + +def test_approved_review_by_roster_login_without_role_does_not_count(): + for association in ("CONTRIBUTOR", "NONE", "FIRST_TIME_CONTRIBUTOR"): + decision = decide(facts(reviews=(review(association=association),)), TODAY) + assert outcome(decision) == ("queue", None, ("no_accepted",)) + stranger = decide(facts(reviews=(review(login="someone", association="MEMBER"),)), TODAY) + assert stranger.outcome == "queue" + + +def test_commented_reviews_do_not_count(): + decision = decide(facts(reviews=(review(state="COMMENTED"), review(state="DISMISSED"))), TODAY) + assert outcome(decision) == ("queue", None, ("no_accepted",)) + + +def test_changes_requested_at_29_days_passes_and_at_31_days_is_sent_back_or_awaiting(): + fresh = decide(facts(reviews=(review(state="CHANGES_REQUESTED", days=29),)), TODAY) + assert outcome(fresh) == ("pass", "review", ()) + edge = decide(facts(reviews=(review(state="CHANGES_REQUESTED", days=30),)), TODAY) + assert edge.outcome == "pass" + stale = facts(reviews=(review(state="CHANGES_REQUESTED", days=31),)) + assert outcome(decide(stale, TODAY)) == ("queue", None, ("sent_back", "no_accepted")) + replied = dataclasses.replace(stale, last_author_comment_at=datetime.combine( + TODAY - timedelta(days=5), datetime.min.time(), tzinfo=timezone.utc)) + assert decide(replied, TODAY).reasons == ("awaiting_rereview", "no_accepted") + # A newer roster review, even a comment, means the lead has looked again. + relooked = dataclasses.replace(stale, reviews=stale.reviews + (review(state="COMMENTED", days=20),)) + assert decide(relooked, TODAY).reasons == ("no_accepted",) + + +def test_push_after_changes_requested_is_awaiting_rereview_not_sent_back(): + decision = decide(facts(reviews=(review(state="CHANGES_REQUESTED", days=45, commit="c" * 40),)), TODAY) + assert decision.reasons[0] == "awaiting_rereview" + assert "sent_back" not in decision.reasons + assert decision.data["reason"] == "awaiting_rereview" + assert "re-review" in decision.comment or "second review" in decision.comment + + +def test_hand_clear_records_the_head_and_holds_while_it_matches(): + queued = facts(labels=frozenset({NEEDS_TRIAGE})) + first = decide(queued, TODAY) + unlabeled = dataclasses.replace( + with_comment(queued, first), labels=frozenset(), last_unlabel=("rocklambros", OPENED + timedelta(days=1)) + ) + cleared = decide(unlabeled, TODAY) + assert outcome(cleared) == ("pass", "hand", ()) + assert cleared.data["cleared_head"] == HEAD and cleared.data["reason"] == "cleared" + assert cleared.data["route"] == "hand" and cleared.data["cleared"] == TODAY.isoformat() + assert cleared.label == "keep" and cleared.comment is not None + held = decide(with_comment(unlabeled, cleared), TODAY + timedelta(days=3)) + assert outcome(held) == ("pass", "hand", ()) + assert held.label == "keep" and held.comment is None + + +def test_hand_clear_stops_holding_when_the_head_sha_changes(): + queued = facts(labels=frozenset({NEEDS_TRIAGE})) + unlabeled = dataclasses.replace( + with_comment(queued, decide(queued, TODAY)), labels=frozenset(), last_unlabel=("lrd0036", OPENED) + ) + cleared = with_comment(unlabeled, decide(unlabeled, TODAY)) + pushed = dataclasses.replace(cleared, head_sha="b" * 40) + requeued = decide(pushed, TODAY) + assert outcome(requeued) == ("queue", None, ("no_accepted",)) + assert requeued.label == "set" and requeued.comment is not None + # The recorded head is carried, so the old unlabel cannot clear the new head next run. + assert requeued.data["cleared_head"] == HEAD + assert decide(with_comment(pushed, requeued), TODAY).outcome == "queue" + + +def test_hand_clear_by_non_roster_is_reversed(): + queued = facts(labels=frozenset({NEEDS_TRIAGE})) + removed = dataclasses.replace( + with_comment(queued, decide(queued, TODAY)), labels=frozenset(), last_unlabel=("outsider", OPENED) + ) + decision = decide(removed, TODAY) + assert outcome(decision) == ("queue", None, ("no_accepted",)) + assert decision.label == "set" and "cleared_head" not in decision.data + assert "someone other than a lead" in decision.comment + # The bot's own removal on an earlier clear is not a removal to reverse in words. + by_bot = decide(dataclasses.replace(removed, last_unlabel=(roadmap_model.BOT_LOGIN, OPENED)), TODAY) + assert by_bot.label == "set" and "someone other than a lead" not in by_bot.comment + + +def test_author_merged_record_passes(): + assert outcome(decide(facts(author_record=True), TODAY)) == ("pass", "record", ()) + + +def test_editorial_on_the_docs_lane_passes(): + decision = decide(facts(body=TICKED, paths=("docs/topics/a.md", "design/b.md", "README.md")), TODAY) + assert outcome(decision) == ("pass", "editorial", ()) + + +def test_editorial_off_lane_queues_naming_sanitized_paths(): + hostile = "specification/v`1@y #1.json" + decision = decide(facts(body=TICKED, paths=("docs/topics/a.md", hostile, "docs/spec/b.md")), TODAY) + assert outcome(decision) == ("queue", None, ("editorial_off_lane",)) + assert f"`{safe_path(hostile)}`" in decision.comment and "`docs/spec/b.md`" in decision.comment + assert "`docs/topics/a.md`" not in decision.comment + assert hostile not in decision.comment + + +def test_motivating_example_queues(): + # PR #354: editorial box ticked, one path under reference-implementations/, Refs #92 on a + # help wanted issue the author commented on two days earlier. The tick on an off-lane path + # queues before the help wanted route is read. + claimed = issue(92, labels=(roadmap_model.ACCEPTED, "help wanted"), commented=OPENED - timedelta(days=2)) + f = facts(body=TICKED + "\n\nRefs #92", paths=("reference-implementations/x.py",), references=(claimed,), + held_use={92: (354,)}) + decision = decide(f, TODAY) + assert outcome(decision) == ("queue", None, ("editorial_off_lane",)) + assert unmet_routes(f, TODAY) == ("roster", "engaged", "record", "editorial") + assert "`reference-implementations/x.py`" in decision.comment + # Without the tick the same pull request passes on the claim. + assert outcome(decide(dataclasses.replace(f, body="Refs #92"), TODAY)) == ("pass", "help_wanted", ()) + + +def test_truncated_paths_queue_with_their_own_reason_and_never_pass_editorial(): + on_lane = tuple(f"docs/topics/p{n}.md" for n in range(3000)) + decision = decide(facts(body=TICKED, paths=on_lane, paths_truncated=True), TODAY) + assert outcome(decision) == ("queue", None, ("paths_truncated",)) + assert decision.comment.count("`docs/topics/") <= 1 + both = decide(facts(body=TICKED, paths=("src/x.py",), paths_truncated=True), TODAY) + assert both.reasons == ("editorial_off_lane", "paths_truncated") + # Truncation without the tick is not a reason: the editorial route was never claimed. + assert decide(facts(paths=on_lane, paths_truncated=True), TODAY).reasons == ("no_accepted",) + + +def test_first_two_pull_requests_on_a_held_issue_pass_and_the_third_queues(): + held = issue(40, assignees=("outsider",)) + use = {40: (300, 354, 400)} + for number in (300, 354): + assert outcome(decide(facts(number=number, references=(held,), held_use=use), TODAY)) == ("pass", "assignment", ()) + third = decide(facts(number=400, references=(held,), held_use=use), TODAY) + assert outcome(third) == ("queue", None, ("held_cap",)) + assert "#40 already clears PR #300 and PR #354." in third.comment + + +def test_first_two_pull_requests_on_a_help_wanted_issue_pass_and_the_third_queues(): + wanted = issue(41, labels=(roadmap_model.ACCEPTED, "help wanted"), commented=OPENED - timedelta(days=1)) + use = {41: (354, 360, 365)} + for number in (354, 360): + assert outcome(decide(facts(number=number, references=(wanted,), held_use=use), TODAY)) == ("pass", "help_wanted", ()) + third = decide(facts(number=365, references=(wanted,), held_use=use), TODAY) + assert outcome(third) == ("queue", None, ("held_cap",)) + assert "#41 already clears PR #354 and PR #360." in third.comment + + +def test_issue_author_without_assignment_queues(): + decision = decide(facts(references=(issue(42),), held_use={42: (354,)}), TODAY) + assert outcome(decision) == ("queue", None, ("not_held",)) + assert "This pull request references #42, which is accepted and is not assigned to you." in decision.comment + assert "To claim one, comment on it" in decision.comment + + +def test_help_wanted_with_prior_comment_passes_and_without_queues(): + labels = (roadmap_model.ACCEPTED, "help wanted") + claimed = issue(43, labels=labels, commented=OPENED - timedelta(hours=1)) + assert outcome(decide(facts(references=(claimed,), held_use={43: (354,)}), TODAY)) == ("pass", "help_wanted", ()) + silent = issue(43, labels=labels) + assert outcome(decide(facts(references=(silent,), held_use={43: (354,)}), TODAY)) == ("queue", None, ("not_held",)) + + +def test_help_wanted_comment_after_open_does_not_count(): + late = issue(92, labels=(roadmap_model.ACCEPTED, "help wanted"), commented=datetime(2026, 11, 3, 9, tzinfo=timezone.utc)) + decision = decide(facts(references=(late,), held_use={92: (354,)}), TODAY) + assert outcome(decision) == ("queue", None, ("not_held",)) + assert "You commented on #92 on 2026-11-03. If that was a claim, it is waiting on a lead." in decision.comment + assert decision.data["claimed"] == "92:2026-11-03" + + +def test_closed_accepted_reference_gets_its_own_reason(): + decision = decide(facts(references=(issue(44, state="closed"),)), TODAY) + assert outcome(decision) == ("queue", None, ("closed_accepted",)) + assert "This pull request references #44, which was accepted and is now closed." in decision.comment + + +def test_reference_to_a_pull_request_number_is_not_an_issue(): + as_pull = issue(45, is_pull=True, assignees=("outsider",)) + assert outcome(decide(facts(references=(as_pull,), held_use={45: (354,)}), TODAY)) == ("queue", None, ("no_accepted",)) + unknown = issue(46, unresolved=True, assignees=("outsider",)) + assert decide(facts(references=(unknown,)), TODAY).reasons == ("no_accepted",) + + +def test_body_failing_two_routes_renders_both_paragraphs(): + f = facts(references=(issue(10, state="closed"), issue(11), issue(12)), held_use={11: (354,), 12: (354,)}) + decision = decide(f, TODAY) + assert outcome(decision) == ("queue", None, ("closed_accepted", "not_held")) + assert unmet_routes(f, TODAY) == ("roster", "engaged", "record", "editorial", "held", "help_wanted") + assert "#10, which was accepted and is now closed" in decision.comment + assert "#11 and #12, which are accepted, and neither is assigned to you." in decision.comment + assert decision.comment.index("#10, which") < decision.comment.index("#11 and #12") + assert decision.data["issues"] == "10,11,12" + + +def test_prior_comment_is_named_as_possibly_waiting(): + asked = issue(47, commented=OPENED - timedelta(days=4)) + decision = decide(facts(references=(asked, issue(48)), held_use={47: (354,), 48: (354,)}), TODAY) + assert decision.reasons == ("not_held",) + assert "You commented on #47 on 2026-10-29. If that was a claim, it is waiting on a lead." in decision.comment + assert "#48 on" not in decision.comment + assert "To claim one" not in decision.comment + assert decision.data["claimed"] == "47:2026-10-29" + + +def test_legacy_mode_matches_todays_three_exits(): + queued = decide(facts(mode="legacy"), TODAY) + assert queued.outcome == "queue" and queued.label == "set" and queued.data is None + assert queued.comment.startswith(pr_intake.LEGACY_PREFIX) + assert queued.disable_auto_merge is False + exits = { + "accepted, even closed": facts(mode="legacy", references=(issue(50, state="closed"),)), + "editorial ticked": facts(mode="legacy", body=TICKED, paths=("src/x.py",)), + "already commented": facts(mode="legacy", bot_comment=BotComment(id=9, body=pr_intake.LEGACY_PREFIX, data=None, legacy=True)), + } + for name, f in exits.items(): + decision = decide(f, TODAY) + assert decision.label == "keep" and decision.comment is None, name + assert decision.notices, name + # Legacy never clears a label, even for a roster author. + roster = decide(facts(mode="legacy", author="rocklambros", labels=frozenset({NEEDS_TRIAGE}), + bot_comment=BotComment(id=9, body=pr_intake.LEGACY_PREFIX, data=None, legacy=True)), TODAY) + assert roster.label == "keep" and roster.comment is None + + +def test_legacy_sweep_writes_nothing(): + decision = decide(facts(mode="legacy", sweep=True), TODAY) + assert decision.label == "keep" and decision.comment is None and decision.disable_auto_merge is False + assert any("queue" in notice for notice in decision.notices) + + +def test_pre_rule_pull_request_comment_says_the_rule_is_new(): + old = decide(facts(created_at=datetime(2026, 10, 1, tzinfo=timezone.utc)), TODAY) + assert "The rule is new, and its 30-day clock starts on 2026-11-10." in old.comment + assert "The rule is new" not in decide(facts(), TODAY).comment + + +def test_keyword_warning_present_when_a_closing_keyword_names_a_milestoned_issue(): + roadmap = issue(60, kind="close", labels=(), milestoned=True) + decision = decide(facts(references=(roadmap,)), TODAY) + assert "says it closes #60, which is on the project roadmap" in decision.comment + # A roster author is not queued, but the merger still gets the warning. + lead = decide(facts(author="rocklambros", references=(roadmap,)), TODAY) + assert lead.outcome == "pass" and "which is on the project roadmap" in lead.comment + for quiet in (issue(60, kind="contribute", labels=(), milestoned=True), issue(60, kind="close", labels=())): + assert "project roadmap" not in decide(facts(references=(quiet,)), TODAY).comment + + +def test_auto_merge_disabled_whenever_on_for_a_non_roster_milestoned_close(): + roadmap = issue(61, kind="close", labels=(), milestoned=True) + passing = decide(facts(auto_merge=True, author_record=True, references=(roadmap,)), TODAY) + assert passing.outcome == "pass" and passing.disable_auto_merge is True + assert ("Auto-merge is off because this pull request closes roadmap issue #61. " + "A lead merges it by hand after confirming delivery.") in passing.comment + for event in ("opened", "edited", "synchronize", "auto_merge_enabled", None): + assert decide(facts(auto_merge=True, author_record=True, references=(roadmap,), event_action=event), TODAY).disable_auto_merge + assert decide(facts(auto_merge=True, author="rocklambros", references=(roadmap,)), TODAY).disable_auto_merge is False + assert decide(facts(auto_merge=False, author_record=True, references=(roadmap,)), TODAY).disable_auto_merge is False + closed = issue(61, kind="close", labels=(), milestoned=True, state="closed") + assert decide(facts(auto_merge=True, author_record=True, references=(closed,)), TODAY).disable_auto_merge is False + # A queued pull request never merges itself either. + assert decide(facts(auto_merge=True), TODAY).disable_auto_merge is True + + +def test_unchanged_facts_keep_label_and_comment(): + f = facts(references=(issue(70),), held_use={70: (354,)}) + first = decide(f, TODAY) + again = decide(with_comment(f, first), TODAY + timedelta(days=2)) + assert outcome(again) == outcome(first) + assert again.label == "keep" and again.comment is None + + +# One changed value per Facts field. The set of keys must equal the dataclass fields, so a +# field added later fails here until someone decides what changing it means. +def _mutations(base: Facts) -> dict: + return { + "number": 999, + "state": "closed", + "draft": True, + "author": "rocklambros", + "author_is_bot": True, + "title": "Fixes #92", + "body": TICKED, + "base": "main", + "head_sha": "b" * 40, + # Before the author's comment on #92, so the claim no longer counts. + "created_at": OPENED - timedelta(days=10), + "updated_at": OPENED + timedelta(days=3), + "labels": frozenset({NEEDS_TRIAGE}), + "paths": ("docs/topics/a.md",), + "paths_truncated": True, + "references": base.references + (issue(93),), + "references_dropped": 3, + "reviews": (), + "last_unlabel": ("rocklambros", OPENED), + "last_author_comment_at": OPENED + timedelta(days=60), + "bot_comment": None, + "auto_merge": True, + "author_record": True, + "held_use": {92: (354, 400, 500)}, + "roster": base.roster | {"outsider"}, + "mode": "legacy", + "sweep": True, + "event_action": "edited", + } + + +@pytest.mark.parametrize("age", [29, 31]) +@pytest.mark.parametrize("field", [f.name for f in dataclasses.fields(Facts)]) +def test_hash_short_circuit_cannot_hide_a_changed_decision(field, age): + # The review is 30 days before TODAY, so `today` sits at 29 or 31 days past it. At 31 the + # base queues with sent_back first and held_cap second, the shape where a change behind + # the first reason is the one a hash gap would hide. + submitted = TODAY - timedelta(days=30) + today = submitted + timedelta(days=age) + wanted = issue(92, labels=(roadmap_model.ACCEPTED, "help wanted"), commented=OPENED - timedelta(days=2)) + base = facts( + references=(wanted,), held_use={92: (100, 200, 354)}, + reviews=(review(state="CHANGES_REQUESTED", days=30, today=TODAY),), + ) + mutations = _mutations(base) + assert set(mutations) == {f.name for f in dataclasses.fields(Facts)} + before = decide(base, today) + stored = with_comment(base, before) if before.comment else base + changed = dataclasses.replace(stored, **{field: mutations[field]}) + fresh = decide(dataclasses.replace(base, **{field: mutations[field]}), today) + after = decide(changed, today) + if (fresh.outcome, fresh.reasons) != (before.outcome, before.reasons) and after.outcome != "skip" and changed.mode == "enforced": + assert after.comment is not None, field + if age == 31: + assert before.reasons == ("sent_back", "held_cap") + # The same facts read on the other side of the 30-day line always render. + other = submitted + timedelta(days=60 - age) + crossed = decide(with_comment(base, decide(base, other)), today) + assert crossed.comment is not None + + +def test_since_survives_a_rerender_and_resets_on_clear(): + day0 = TODAY + f = facts(references=(issue(80),), held_use={80: (354,)}) + first = decide(f, day0) + assert first.data["since"] == day0.isoformat() + grown = dataclasses.replace(with_comment(f, first), references=(issue(80), issue(81, state="closed"))) + rerendered = decide(grown, day0 + timedelta(days=5)) + assert rerendered.comment is not None and rerendered.reasons == ("closed_accepted", "not_held") + assert rerendered.data["since"] == day0.isoformat() + cleared = decide(dataclasses.replace(with_comment(grown, rerendered), author_record=True), day0 + timedelta(days=6)) + assert cleared.outcome == "pass" and "since" not in cleared.data + assert cleared.data["cleared"] == (day0 + timedelta(days=6)).isoformat() + after = dataclasses.replace(with_comment(grown, cleared), author_record=False) + requeued = decide(after, day0 + timedelta(days=9)) + assert requeued.data["since"] == (day0 + timedelta(days=9)).isoformat() + + +def test_render_comment_contains_one_html_comment_pair_and_no_at_sign(): + hostile = ("x/`code`.md", "y/.md", "z/@someone.md", "w/#1.md", "v/-->.md") + tuple( + f"src/{'q' * 300}{n}.py" for n in range(30) + ) + claimed = issue(9, commented=OPENED - timedelta(days=1)) + f = facts(body=TICKED, paths=hostile, references=(claimed,), references_dropped=7, author="mallory@example") + decision = decide(f, TODAY) + for text in (decision.comment, render_comment(f, decision, TODAY)): + assert text.count("") == 2 + assert "@" not in text and "mallory" not in text + lines = text.split("\n") + assert parse_data_line(lines[0]) == decision.data and lines[1] == pr_intake.MARKER + assert len(text) <= 8000 + assert "and 15 more" in text + # Every name the bot quotes comes out of a code span, at most 200 characters each. + assert "`x/?code?.md`" in decision.comment + long_issue = facts(references=(issue(9),), references_dropped=4) + assert "and 4 more" in decide(long_issue, TODAY).comment + + +def _from_json(row: dict) -> Facts: + def when(value): + return None if value is None else datetime.fromisoformat(value.replace("Z", "+00:00")) + + raw = dict(row) + for key in ("created_at", "updated_at", "last_author_comment_at"): + if key in raw: + raw[key] = when(raw[key]) + for key in ("labels", "roster"): + if key in raw: + raw[key] = frozenset(raw[key]) + if "paths" in raw: + raw["paths"] = tuple(raw["paths"]) + if "references" in raw: + raw["references"] = tuple( + Reference(**{**ref, "labels": frozenset(ref["labels"]), "assignees": frozenset(ref["assignees"]), + "author_commented_at": when(ref.get("author_commented_at"))}) + for ref in raw["references"] + ) + if "reviews" in raw: + raw["reviews"] = tuple(Review(**{**r, "submitted_at": when(r.get("submitted_at"))}) for r in raw["reviews"]) + if raw.get("last_unlabel") is not None: + raw["last_unlabel"] = (raw["last_unlabel"][0], when(raw["last_unlabel"][1])) + if "held_use" in raw: + raw["held_use"] = {int(n): tuple(numbers) for n, numbers in raw["held_use"].items()} + if raw.get("bot_comment") is not None: + raw["bot_comment"] = BotComment(**raw["bot_comment"]) + return facts(**raw) + + +@pytest.mark.skipif(not POPULATION.exists(), reason="recorded by Task 4") +def test_decide_matches_the_recorded_population(): + """Every recorded open pull request decides as recorded. + + The fixture is a JSON list. Each row is an object: + `{"facts": {: , ...}, "today": "YYYY-MM-DD", "expected": + {"outcome": "pass"|"queue"|"skip", "route": , "reasons": [, ...]}}`. + `facts` holds overrides on this module's `facts()` defaults in JSON form: datetimes as + ISO 8601 strings, sets and tuples as lists, `references` and `reviews` as lists of + objects with the dataclass fields, `last_unlabel` as `[login, datetime]`, `held_use` + with string keys, and `bot_comment` as an object with the `BotComment` fields. `today` + is optional and defaults to TODAY. + """ + rows = json.loads(POPULATION.read_text()) + assert rows + for row in rows: + today = date.fromisoformat(row.get("today", TODAY.isoformat())) + decision = decide(_from_json(row["facts"]), today) + expected = row["expected"] + assert outcome(decision) == (expected["outcome"], expected["route"], tuple(expected["reasons"])), row["facts"].get("number") diff --git a/tools/pr_intake.py b/tools/pr_intake.py index d92c2a0d..3fa05443 100644 --- a/tools/pr_intake.py +++ b/tools/pr_intake.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 """Decide whether a pull request has an accepted issue behind it, and say so once. -Version 1.0. Owner: ACS project leads. This module holds the data model and the -parsing helpers. The gate logic, the collectors, and the writers join it in later -tasks of the same plan. +Version 1.0. Owner: ACS project leads. This module holds the data model, the parsing +helpers, and the gate: `decide` is a pure function of `Facts` and the date, with no I/O. +The collectors and the writers join it in later tasks of the same plan. The workflow runs this under `python3 -I -S` from a checkout of `main`, never the pull request's head, with the standard library and the sibling modules under `tools/` only. @@ -18,10 +18,12 @@ import hashlib import re -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import date, datetime, timedelta +import base_branch_guard import closing_choice +import roadmap_model # The marker sits on line 2 of the bot comment. The data line sits on line 1, ahead of it. MARKER = "" @@ -201,8 +203,11 @@ def facts_hash(facts: Facts, today: date) -> str: too, because the gate compares it with `head_sha`. Two derived booleans enter as well: `engaged_by_fresh_review`, which carries review freshness, and `editorial_ticked`. - Excluded: `updated_at` and `created_at`, because any comment moves `updated_at` and - the bot's own writes would change the hash. Raw review timestamps, whose one effect is + `created_at` enters because the help wanted route compares the claim with it, and a + reason behind the first one may turn on it. It never changes on a real pull request. + + Excluded: `updated_at`, because any comment moves it and the bot's own writes would + change the hash. Raw review timestamps, whose one effect is freshness, already carried by the derived boolean. `sweep` and `event_action`, which describe why the run started, not the pull request. `title` and `body`, which enter only through `editorial_ticked`, so an edit that changes no fact keeps the comment. @@ -215,6 +220,7 @@ def facts_hash(facts: Facts, today: date) -> str: "author_is_bot": facts.author_is_bot, "base": facts.base, "head_sha": facts.head_sha, + "created_at": facts.created_at, "labels": sorted(facts.labels), "paths": list(facts.paths), "paths_truncated": facts.paths_truncated, @@ -248,3 +254,494 @@ def facts_hash(facts: Facts, today: date) -> str: def safe_path(path: str) -> str: """A changed file's path made safe to print. Backticks, markup, and mentions become `?`.""" return _UNSAFE_PATH.sub("?", path)[:200] + + +# --- The gate --------------------------------------------------------------------------- + +GATE_ORDER = ("bot", "roster", "engaged", "record", "editorial", "held", "help_wanted") +LEAD_ASSOCIATIONS = frozenset({"OWNER", "MEMBER", "COLLABORATOR"}) +HELP_WANTED = "help wanted" +HELD_CAP = 2 +MAX_PATHS = 20 +MAX_COMMENT = 8_000 +REPO_URL = f"https://github.com/{roadmap_model.REPO}" + +QUEUED_LINE = "This pull request is queued rather than ignored. Nothing here is rejected." +LEGACY_COMMENT = ( + LEGACY_PREFIX + + "\n\nThis pull request does not reference an issue carrying `status:accepted`, so a maintainer has not" + " looked at it yet and will not until the underlying issue is triaged. Nothing here is rejected. See" + f" [Current Priority Scope]({REPO_URL}/blob/main/CONTRIBUTING.md#current-priority-scope) for what the" + f" project is working on, and [`help wanted`]({REPO_URL}/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22)" + " for work that is already accepted." + "\n\nIf this is an editorial correction, tick that box in the description and this comment stops applying." +) +CLEARED_LINES = { + "roster": "Its author is a project lead, a workstream lead, a triage volunteer, or a code owner.", + "review": "A lead has reviewed it.", + "hand": "A lead removed the queue label. That holds until the next push.", + "record": "A lead approved and merged an earlier pull request by its author.", + "editorial": "It is an editorial correction, and every changed file is on the documentation lane.", + "assignment": "It references an accepted issue assigned to its author.", + "help_wanted": "It references a `help wanted` issue its author commented on before opening it.", +} +_GATE_TO_CLEAR = {"roster": "roster", "record": "record", "editorial": "editorial", "held": "assignment", + "help_wanted": "help_wanted"} + + +def _on_roster(login: str, facts: Facts) -> bool: + return login.casefold() in facts.roster + + +def _lead_reviews(facts: Facts) -> list[Review]: + """Reviews that speak for the project: a roster login that also holds a repository role.""" + return [r for r in facts.reviews if _on_roster(r.login, facts) and r.association in LEAD_ASSOCIATIONS] + + +def _fresh(review: Review, today: date) -> bool: + return review.submitted_at is not None and review.submitted_at.date() >= today - timedelta(days=FRESH_REVIEW_DAYS) + + +def _stored(facts: Facts) -> dict[str, str]: + """The bot's own state from its last comment. A legacy comment carries none.""" + if facts.bot_comment is None or facts.bot_comment.data is None: + return {} + return facts.bot_comment.data + + +def _hand_clear(facts: Facts) -> tuple[bool, bool]: + """Whether a lead's removal of the label stands, and whether this run must record the head. + + The clear is bound to the head it was made at, so a push after it does not ride on it. + The first run that sees the removal records the head. Until then nothing is recorded. + """ + if facts.last_unlabel is None or not _on_roster(facts.last_unlabel[0], facts): + return False, False + recorded = _stored(facts).get("cleared_head") + if recorded is None: + return True, True + return recorded == facts.head_sha, False + + +def _engaged(facts: Facts, today: date) -> str | None: + for review in _lead_reviews(facts): + if review.state == "APPROVED" or (review.state == "CHANGES_REQUESTED" and _fresh(review, today)): + return "review" + return "hand" if _hand_clear(facts)[0] else None + + +def _off_lane(facts: Facts) -> tuple[str, ...]: + return tuple(path for path in facts.paths if not base_branch_guard.in_docs_lane(path)) + + +def _editorial(facts: Facts) -> str | None: + """None when the box is not ticked, else "pass" or "queue".""" + if not editorial_ticked(facts.body): + return None + # A truncated listing may hide an off-lane path, so it never passes. + return "queue" if facts.paths_truncated or _off_lane(facts) else "pass" + + +def _accepted_issues(facts: Facts) -> list[Reference]: + """Referenced issues carrying status:accepted. A pull request number is never an issue.""" + return [ + ref for ref in facts.references + if not ref.is_pull and not ref.unresolved and roadmap_model.ACCEPTED in ref.labels + ] + + +def _is_open(ref: Reference) -> bool: + return (ref.state or "").casefold() == "open" + + +def _uses(facts: Facts, number: int) -> tuple[int, ...]: + # The collector fills held_use for every referenced open accepted issue. A missing entry + # means this pull request is the only one, which is the reading that cannot exceed the cap. + return facts.held_use.get(number, (facts.number,)) + + +def _within_cap(facts: Facts, number: int) -> bool: + return facts.number in _uses(facts, number)[:HELD_CAP] + + +def _assigned(facts: Facts, ref: Reference) -> bool: + return facts.author.casefold() in {login.casefold() for login in ref.assignees} + + +def _claimed_help(facts: Facts, ref: Reference) -> bool: + return ( + HELP_WANTED in ref.labels + and ref.author_commented_at is not None + and ref.author_commented_at < facts.created_at + ) + + +def _route_holds(name: str, facts: Facts, today: date) -> bool: + open_accepted = [ref for ref in _accepted_issues(facts) if _is_open(ref)] + if name == "roster": + return _on_roster(facts.author, facts) + if name == "engaged": + return _engaged(facts, today) is not None + if name == "record": + return facts.author_record + if name == "held": + return any(_assigned(facts, ref) and _within_cap(facts, ref.number) for ref in open_accepted) + if name == "help_wanted": + return any(_claimed_help(facts, ref) and _within_cap(facts, ref.number) for ref in open_accepted) + raise ValueError(f"unknown route {name}") + + +def _walk(facts: Facts, today: date) -> tuple[str | None, tuple[str, ...]]: + """The gate route that passed, or None, and the routes that did not hold before it.""" + if facts.author in KNOWN_BOTS or facts.draft: + return "bot", () + unmet: list[str] = [] + for name in GATE_ORDER[1:]: + if name == "editorial": + state = _editorial(facts) + if state == "pass": + return name, tuple(unmet) + unmet.append(name) + # A tick on an off-lane path is a claim the bot can check and refute, so it queues + # before the issue routes are read. PR #354 is the case this exists for. + if state == "queue": + return None, tuple(unmet) + continue + if _route_holds(name, facts, today): + return name, tuple(unmet) + unmet.append(name) + return None, tuple(unmet) + + +def unmet_routes(facts: Facts, today: date) -> tuple[str, ...]: + """The routes in GATE_ORDER the gate read and found unmet, in order.""" + return _walk(facts, today)[1] + + +def _stale_review(facts: Facts, today: date) -> str | None: + """sent_back or awaiting_rereview when a lead's newest review asked for changes too long ago.""" + dated = [review for review in _lead_reviews(facts) if review.submitted_at is not None] + if not dated: + return None + newest = max(dated, key=lambda review: review.submitted_at) + if newest.state != "CHANGES_REQUESTED" or _fresh(newest, today): + return None + pushed = newest.commit_id is not None and newest.commit_id != facts.head_sha + replied = facts.last_author_comment_at is not None and facts.last_author_comment_at > newest.submitted_at + return "awaiting_rereview" if pushed or replied else "sent_back" + + +def _issue_reasons(facts: Facts) -> set[str]: + accepted = _accepted_issues(facts) + found: set[str] = set() + if not accepted: + found.add("no_accepted") + for ref in accepted: + if not _is_open(ref): + found.add("closed_accepted") + elif _assigned(facts, ref) or _claimed_help(facts, ref): + # Held or claimed, and the routes still failed, so the cap is what stopped it. + found.add("held_cap") + else: + found.add("not_held") + return found + + +def _reasons(facts: Facts, today: date) -> tuple[str, ...]: + """Why the pull request is queued, in gate order: the review first, then the rest.""" + stale = _stale_review(facts, today) + lead = (stale,) if stale else () + if _editorial(facts) == "queue": + found = {"editorial_off_lane"} if _off_lane(facts) else set() + if facts.paths_truncated: + found.add("paths_truncated") + else: + found = _issue_reasons(facts) + return lead + tuple(reason for reason in REASONS if reason in found) + + +def _roadmap_closes(facts: Facts) -> tuple[int, ...]: + """Open milestoned issues this pull request closes by keyword, which merging would deliver.""" + return tuple(sorted({ + ref.number for ref in facts.references + if ref.kind == "close" and not ref.is_pull and ref.milestoned and _is_open(ref) + })) + + +def _auto_merge_off(facts: Facts, queued: bool) -> bool: + # Keyed on state, not on the event, so enabling auto-merge after an edit is caught too. + if not facts.auto_merge: + return False + return queued or (not _on_roster(facts.author, facts) and bool(_roadmap_closes(facts))) + + +def _claims(facts: Facts) -> list[tuple[int, date]]: + """Comments by the author on accepted open issues they do not hold, which may be claims.""" + return sorted( + (ref.number, ref.author_commented_at.date()) + for ref in _accepted_issues(facts) + if _is_open(ref) and not _assigned(facts, ref) and ref.author_commented_at is not None + ) + + +def _hash_as_read_next(facts: Facts, cleared_head: str | None, today: date) -> str: + """The facts hash the next run computes, once this run's cleared_head is on the comment.""" + if cleared_head == _stored(facts).get("cleared_head"): + return facts_hash(facts, today) + comment = BotComment(id=0, body="", data={"cleared_head": cleared_head} if cleared_head else None, legacy=False) + return facts_hash(replace(facts, bot_comment=comment), today) + + +def _data(facts: Facts, today: date, route: str | None, reasons: tuple[str, ...], cleared_head: str | None) -> dict[str, str]: + stored = _stored(facts) + data: dict[str, str] = {} + if route is not None: + data["reason"] = "cleared" + data["route"] = route + same = stored.get("reason") == "cleared" and stored.get("route") == route + data["cleared"] = stored["cleared"] if same and "cleared" in stored else today.isoformat() + else: + data["reason"] = reasons[0] + issues = sorted({ref.number for ref in _accepted_issues(facts)}) + if issues: + data["issues"] = ",".join(str(number) for number in issues) + # The queue clock runs from the first queue and survives re-renders, so the health + # issue's day count is the time waited, not the time since the last edit. + queued_before = stored.get("reason") not in (None, "cleared") and "since" in stored + data["since"] = stored["since"] if queued_before else today.isoformat() + claims = _claims(facts) + if claims: + data["claimed"] = ",".join(f"{number}:{when.isoformat()}" for number, when in claims) + data["head"] = facts.head_sha + if cleared_head: + data["cleared_head"] = cleared_head + data["facts"] = _hash_as_read_next(facts, cleared_head, today) + return data + + +def _legacy(facts: Facts, today: date) -> Decision: + """Today's behavior: queue a human's pull request once, and only on an event run.""" + would = _enforced(facts, today) + notices = ( + f"Would {would.outcome} once enforced: route={would.route or 'none'}" + f" reasons={','.join(would.reasons) or 'none'} auto_merge_off={would.disable_auto_merge}", + ) + any_accepted = any( + roadmap_model.ACCEPTED in ref.labels for ref in facts.references if not ref.is_pull + ) + writes = ( + not facts.sweep + and not any_accepted + and not editorial_ticked(facts.body) + and facts.bot_comment is None + and facts.author not in KNOWN_BOTS + ) + if writes: + return Decision("queue", None, ("no_accepted",), "set", LEGACY_COMMENT, None, False, notices) + return Decision("skip", None, (), "keep", None, None, False, notices) + + +def _enforced(facts: Facts, today: date) -> Decision: + gate, _unmet = _walk(facts, today) + stored = _stored(facts) + passed = gate is not None + if passed: + route = _engaged(facts, today) if gate == "engaged" else _GATE_TO_CLEAR[gate] + reasons: tuple[str, ...] = () + else: + route, reasons = None, _reasons(facts, today) + recorded, newly = stored.get("cleared_head"), False + if route == "hand": + newly = _hand_clear(facts)[1] + recorded = facts.head_sha if newly else recorded + data = _data(facts, today, route, reasons, recorded) + labeled = roadmap_model.NEEDS_TRIAGE in facts.labels + decision = Decision( + outcome="pass" if passed else "queue", + route=route, + reasons=reasons, + label=("clear" if labeled else "keep") if passed else "set", + comment=None, + data=data, + disable_auto_merge=_auto_merge_off(facts, not passed), + notices=(), + ) + if stored.get("facts") == data["facts"] and stored.get("reason") == data["reason"]: + return Decision(decision.outcome, route, reasons, "keep", None, stored, decision.disable_auto_merge, ()) + # A pass on a pull request the bot never spoke on stays silent, unless the merger needs + # the roadmap warning or a hand clear must be recorded. + if passed and facts.bot_comment is None and not labeled and not _roadmap_closes(facts) and not newly: + return decision + return Decision( + decision.outcome, route, reasons, decision.label, render_comment(facts, decision, today), data, + decision.disable_auto_merge, (), + ) + + +def decide(facts: Facts, today: date) -> Decision: + """The gate's decision for one pull request: what to label, say, and record. No I/O.""" + if facts.author in KNOWN_BOTS or facts.draft: + return Decision("skip", None, (), "keep", None, None, False, ()) + if facts.mode == "legacy": + return _legacy(facts, today) + return _enforced(facts, today) + + +# --- The comment ------------------------------------------------------------------------ + +def _numbers(numbers: list[int] | tuple[int, ...], prefix: str = "#") -> str: + """#a, or #a and #b, or #a, #b, and #c.""" + names = [f"{prefix}{number}" for number in numbers] + if len(names) <= 2: + return " and ".join(names) + return ", ".join(names[:-1]) + ", and " + names[-1] + + +def _paths(paths: tuple[str, ...]) -> str: + shown = ", ".join(f"`{safe_path(path)}`" for path in paths[:MAX_PATHS]) + extra = len(paths) - MAX_PATHS + return shown + (f", and {extra} more" if extra > 0 else "") + + +def _review_paragraph(reason: str) -> str: + if reason == "sent_back": + return ( + "A lead requested changes more than 30 days ago, and nothing has changed since. Push the changes" + " or reply on the review, and a lead looks again." + ) + return ( + "A lead requested changes more than 30 days ago, and this pull request has changed since. It is" + " waiting on the lead's re-review." + ) + + +def _not_held_paragraph(facts: Facts) -> str: + refs = [ + ref for ref in _accepted_issues(facts) + if _is_open(ref) and not _assigned(facts, ref) and not _claimed_help(facts, ref) + ] + numbers = sorted({ref.number for ref in refs}) + if len(numbers) == 1: + lines = [f"This pull request references #{numbers[0]}, which is accepted and is not assigned to you."] + else: + nobody = "neither" if len(numbers) == 2 else "none" + lines = [f"This pull request references {_numbers(numbers)}, which are accepted, and {nobody} is assigned to you."] + claims = _claims(facts) + for number, when in claims: + lines.append(f"You commented on #{number} on {when.isoformat()}. If that was a claim, it is waiting on a lead.") + if not claims: + lines.append( + "To claim one, comment on it, and a lead may assign it. A lead's approval of this pull request," + " or a changes-requested review, also clears the queue by the next nightly run." + ) + return " ".join(lines) + + +def _held_cap_paragraph(facts: Facts) -> str: + capped = sorted({ + ref.number for ref in _accepted_issues(facts) + if _is_open(ref) and (_assigned(facts, ref) or _claimed_help(facts, ref)) + }) + return " ".join( + f"#{number} already clears {_numbers(_uses(facts, number)[:HELD_CAP], 'PR #')}." for number in capped + ) + + +def _reason_paragraph(reason: str, facts: Facts) -> str: + if reason in ("sent_back", "awaiting_rereview"): + return _review_paragraph(reason) + if reason == "editorial_off_lane": + return ( + "The editorial box covers the documentation lane (`docs/topics/`, `design/`, and the root notice" + f" files). These paths are outside it: {_paths(_off_lane(facts))}. Fold the wording change into the" + " next pull request that changes the file, or leave it as a comment on the issue it concerns." + ) + if reason == "paths_truncated": + return ( + "The editorial box covers the documentation lane, but this pull request changes more files than" + " the bot can list, so it cannot confirm that every path is on the lane. A lead reviews it instead." + ) + if reason == "no_accepted": + return ( + "This pull request references no open accepted issue. See" + f" [Current Priority Scope]({REPO_URL}/blob/main/CONTRIBUTING.md#current-priority-scope) and" + f" [`help wanted`]({REPO_URL}/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22)" + " for what the project has asked for." + ) + if reason == "closed_accepted": + closed = sorted({ref.number for ref in _accepted_issues(facts) if not _is_open(ref)}) + if len(closed) == 1: + return f"This pull request references #{closed[0]}, which was accepted and is now closed. New work on it needs a new issue." + return f"This pull request references {_numbers(closed)}, which were accepted and are now closed. New work on them needs a new issue." + if reason == "not_held": + return _not_held_paragraph(facts) + if reason == "held_cap": + return _held_cap_paragraph(facts) + raise ValueError(f"no wording for reason {reason}") + + +def _auto_merge_line(facts: Facts) -> str: + closes = _roadmap_closes(facts) + if closes and not _on_roster(facts.author, facts): + noun = "issue" if len(closes) == 1 else "issues" + return ( + f"Auto-merge is off because this pull request closes roadmap {noun} {_numbers(closes)}." + " A lead merges it by hand after confirming delivery." + ) + return "Auto-merge is off while this pull request is queued." + + +def _keyword_warning(closes: tuple[int, ...]) -> str: + verb = "is" if len(closes) == 1 else "are" + issue_word = "that issue" if len(closes) == 1 else "those issues" + return ( + f"This pull request's description says it closes {_numbers(closes)}, which {verb} on the project" + f" roadmap. When it merges, GitHub closes {issue_word} and the roadmap counts it as delivered." + " Please confirm the change delivers the whole issue before merging." + ) + + +def _reversed_unlabel(facts: Facts) -> bool: + if facts.last_unlabel is None or roadmap_model.NEEDS_TRIAGE in facts.labels: + return False + actor = facts.last_unlabel[0].casefold() + return not _on_roster(actor, facts) and actor != roadmap_model.BOT_LOGIN + + +def render_comment(facts: Facts, decision: Decision, today: date) -> str: + """The bot's one comment: the data line, the marker, then a paragraph per point. + + Nothing from the pull request reaches this text except paths through `safe_path`, + inside code spans, and issue and pull request numbers. No login appears, so the bot + never mentions anyone. + """ + data = decision.data or {} + paragraphs: list[str] = [] + if decision.outcome == "queue": + paragraphs.append(QUEUED_LINE) + paragraphs.extend(_reason_paragraph(reason, facts) for reason in decision.reasons) + if facts.references_dropped: + paragraphs.append( + f"The bot read the first {MAX_REFERENCES} issues this pull request names, and" + f" {facts.references_dropped} more were not read." + ) + if _reversed_unlabel(facts): + paragraphs.append( + "The queue label was removed by someone other than a lead, so the bot set it again." + " A lead's removal clears the queue." + ) + if facts.created_at.date() < RULE_LANDED: + since = data.get("since", today.isoformat()) + paragraphs.append( + "This pull request was opened before the intake rule took effect." + f" The rule is new, and its 30-day clock starts on {since}." + ) + else: + paragraphs.append("This pull request is not queued. " + CLEARED_LINES[decision.route]) + if decision.disable_auto_merge: + paragraphs.append(_auto_merge_line(facts)) + closes = _roadmap_closes(facts) + if closes: + paragraphs.append(_keyword_warning(closes)) + text = render_data_line(data) + "\n" + MARKER + "\n\n" + "\n\n".join(paragraphs) + return text[:MAX_COMMENT] From d325097c481d01356282f99d72f131c8c0836d70 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 20:16:51 -0600 Subject: [PATCH 06/31] Keep the bot's own label writes out of the intake facts hash The queue label and the bot's own unlabel no longer enter the hash, so a write is not followed by a redundant re-render. The data line records cleared_at so a lead's second clear after a push is honored, and the claimed and issues values are capped so decide is total on any facts. Signed-off-by: rocklambros --- tests/test_pr_intake.py | 83 +++++++++++++++++++++++++++++++++++++++-- tools/pr_intake.py | 70 +++++++++++++++++++++++++--------- 2 files changed, 132 insertions(+), 21 deletions(-) diff --git a/tests/test_pr_intake.py b/tests/test_pr_intake.py index 2171d10a..8a2240b9 100644 --- a/tests/test_pr_intake.py +++ b/tests/test_pr_intake.py @@ -184,10 +184,23 @@ def review(*, login="rocklambros", state="APPROVED", association="MEMBER", days= return Review(login=login, state=state, association=association, submitted_at=submitted, commit_id=commit) +BOT_WRITE_AT = datetime(2026, 11, 10, 3, 10, tzinfo=timezone.utc) + + def with_comment(f: Facts, decision) -> Facts: - """The facts as the next run reads them, after the writer posted `decision`'s comment.""" - body = decision.comment - return dataclasses.replace(f, bot_comment=BotComment(id=1, body=body, data=parse_data_line(body), legacy=False)) + """The facts as the next run reads them, after the writer applied `decision`. + + The comment is posted or edited, and the label is set or removed. A removal by the bot + shows up in the timeline as the bot's own unlabel. + """ + if decision.comment is not None: + body = decision.comment + f = dataclasses.replace(f, bot_comment=BotComment(id=1, body=body, data=parse_data_line(body), legacy=False)) + if decision.label == "set": + f = dataclasses.replace(f, labels=f.labels | {NEEDS_TRIAGE}) + elif decision.label == "clear": + f = dataclasses.replace(f, labels=f.labels - {NEEDS_TRIAGE}, last_unlabel=(roadmap_model.BOT_LOGIN, BOT_WRITE_AT)) + return f def outcome(decision): @@ -275,6 +288,7 @@ def test_hand_clear_records_the_head_and_holds_while_it_matches(): cleared = decide(unlabeled, TODAY) assert outcome(cleared) == ("pass", "hand", ()) assert cleared.data["cleared_head"] == HEAD and cleared.data["reason"] == "cleared" + assert cleared.data["cleared_at"] == "2026-11-03T12:00:00Z" assert cleared.data["route"] == "hand" and cleared.data["cleared"] == TODAY.isoformat() assert cleared.label == "keep" and cleared.comment is not None held = decide(with_comment(unlabeled, cleared), TODAY + timedelta(days=3)) @@ -307,8 +321,12 @@ def test_hand_clear_by_non_roster_is_reversed(): assert decision.label == "set" and "cleared_head" not in decision.data assert "someone other than a lead" in decision.comment # The bot's own removal on an earlier clear is not a removal to reverse in words. + # The label is outside the hash, so the short circuit restores it without a comment. by_bot = decide(dataclasses.replace(removed, last_unlabel=(roadmap_model.BOT_LOGIN, OPENED)), TODAY) - assert by_bot.label == "set" and "someone other than a lead" not in by_bot.comment + assert by_bot.label == "set" and by_bot.comment is None + # The reversal renders once. Once the label is back, the next run writes nothing. + settled = decide(with_comment(removed, decision), TODAY) + assert settled.outcome == "queue" and settled.label == "keep" and settled.comment is None def test_author_merged_record_passes(): @@ -499,6 +517,63 @@ def test_unchanged_facts_keep_label_and_comment(): assert again.label == "keep" and again.comment is None +def test_bot_label_writes_do_not_defeat_the_short_circuit(): + f = facts(references=(issue(71),), held_use={71: (354,)}) + queued = decide(f, TODAY) + assert queued.label == "set" and queued.comment is not None + after_queue = with_comment(f, queued) + assert NEEDS_TRIAGE in after_queue.labels + again = decide(after_queue, TODAY) + assert again.outcome == "queue" and again.label == "keep" and again.comment is None + + passing = dataclasses.replace(after_queue, author_record=True) + cleared = decide(passing, TODAY) + assert cleared.label == "clear" and cleared.comment is not None + after_clear = with_comment(passing, cleared) + assert after_clear.last_unlabel[0] == roadmap_model.BOT_LOGIN and NEEDS_TRIAGE not in after_clear.labels + settled = decide(after_clear, TODAY + timedelta(days=1)) + assert settled.outcome == "pass" and settled.label == "keep" and settled.comment is None + + +def test_second_hand_clear_after_a_push_is_a_fresh_clear(): + first_at = OPENED + timedelta(days=1) + queued = facts() + removed = dataclasses.replace( + with_comment(queued, decide(queued, TODAY)), labels=frozenset(), last_unlabel=("rocklambros", first_at) + ) + cleared = decide(removed, TODAY) + assert cleared.data["cleared_head"] == HEAD + pushed = dataclasses.replace(with_comment(removed, cleared), head_sha="b" * 40) + requeued = decide(pushed, TODAY) + assert requeued.outcome == "queue" and requeued.label == "set" + assert requeued.data["cleared_at"] == "2026-11-03T12:00:00Z" + # The same old removal, read again with the label back, still does not clear the new head. + relabeled = with_comment(pushed, requeued) + assert decide(relabeled, TODAY).outcome == "queue" + # A lead removes the label again after the push: a fresh clear at the new head. + second_at = first_at + timedelta(days=2, hours=3) + again = dataclasses.replace(relabeled, labels=frozenset(), last_unlabel=("lrd0036", second_at)) + fresh = decide(again, TODAY) + assert outcome(fresh) == ("pass", "hand", ()) + assert fresh.label == "keep" and fresh.comment is not None + assert fresh.data["cleared_head"] == "b" * 40 and fresh.data["cleared_at"] == "2026-11-05T15:00:00Z" + held = decide(with_comment(again, fresh), TODAY) + assert outcome(held) == ("pass", "hand", ()) and held.comment is None + + +def test_data_line_values_are_capped_so_decide_is_total(): + many = tuple(issue(1000 + n, commented=OPENED - timedelta(days=1)) for n in range(14)) + decision = decide(facts(references=many), TODAY) + assert decision.reasons == ("not_held",) + claimed = decision.data["claimed"].split(",") + assert claimed == [f"{1000 + n}:2026-11-01" for n in range(pr_intake.MAX_CLAIM_READS)] + assert parse_data_line(decision.comment) == decision.data + huge = tuple(issue(999_999_000 + n, commented=OPENED - timedelta(days=1)) for n in range(40)) + wide = decide(facts(references=huge), TODAY) + assert parse_data_line(wide.comment) == wide.data + assert wide.data["issues"].split(",")[0] == "999999000" + + # One changed value per Facts field. The set of keys must equal the dataclass fields, so a # field added later fails here until someone decides what changing it means. def _mutations(base: Facts) -> dict: diff --git a/tools/pr_intake.py b/tools/pr_intake.py index 3fa05443..e7cd17a1 100644 --- a/tools/pr_intake.py +++ b/tools/pr_intake.py @@ -19,7 +19,7 @@ import hashlib import re from dataclasses import dataclass, replace -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone import base_branch_guard import closing_choice @@ -57,7 +57,7 @@ CLEAR_ROUTES = ("roster", "review", "hand", "record", "editorial", "assignment", "help_wanted") DATA_KEYS = frozenset( - {"reason", "route", "issues", "since", "cleared", "head", "cleared_head", "facts", "claimed", "seen"} + {"reason", "route", "issues", "since", "cleared", "head", "cleared_head", "cleared_at", "facts", "claimed", "seen"} ) # Linear: one bounded group repeated at most 12 times, each repeat anchored by a literal # `=` and an optional single space, with no quantified group inside another. @@ -191,6 +191,10 @@ def engaged_by_fresh_review(facts: Facts, today: date) -> bool: ) +def _by_bot(unlabel: tuple[str, datetime] | None) -> bool: + return unlabel is not None and unlabel[0].casefold() == roadmap_model.BOT_LOGIN + + def facts_hash(facts: Facts, today: date) -> str: """Twelve hex characters that change when anything the gate reads changes. @@ -206,8 +210,14 @@ def facts_hash(facts: Facts, today: date) -> str: `created_at` enters because the help wanted route compares the claim with it, and a reason behind the first one may turn on it. It never changes on a real pull request. + `labels` enters without the queue label, and `last_unlabel` enters only when a person + removed the label. The bot owns that label and writes both facts itself, so hashing + them would make every write re-render on the next run. The queue state is already on + the data line as its reason. + Excluded: `updated_at`, because any comment moves it and the bot's own writes would - change the hash. Raw review timestamps, whose one effect is + change the hash. `cleared_at` from the data line, which is bot state that changes only + together with `last_unlabel`, already hashed. Raw review timestamps, whose one effect is freshness, already carried by the derived boolean. `sweep` and `event_action`, which describe why the run started, not the pull request. `title` and `body`, which enter only through `editorial_ticked`, so an edit that changes no fact keeps the comment. @@ -221,7 +231,7 @@ def facts_hash(facts: Facts, today: date) -> str: "base": facts.base, "head_sha": facts.head_sha, "created_at": facts.created_at, - "labels": sorted(facts.labels), + "labels": sorted(facts.labels - {roadmap_model.NEEDS_TRIAGE}), "paths": list(facts.paths), "paths_truncated": facts.paths_truncated, # Sorted by repr so a None beside a datetime or a string never raises TypeError. @@ -238,7 +248,7 @@ def facts_hash(facts: Facts, today: date) -> str: "references_dropped": facts.references_dropped, "reviews": sorted(((r.login, r.state, r.association, r.commit_id) for r in facts.reviews), key=repr), "cleared_head": (facts.bot_comment.data or {}).get("cleared_head") if facts.bot_comment else None, - "last_unlabel": facts.last_unlabel, + "last_unlabel": None if _by_bot(facts.last_unlabel) else facts.last_unlabel, "last_author_comment_at": facts.last_author_comment_at, "auto_merge": facts.auto_merge, "author_record": facts.author_record, @@ -309,18 +319,30 @@ def _stored(facts: Facts) -> dict[str, str]: return facts.bot_comment.data +def _unlabel_time(facts: Facts) -> str: + """The last unlabel's time in the data line's form. A naive time is read as UTC.""" + when = facts.last_unlabel[1] + if when.tzinfo is None: + when = when.replace(tzinfo=timezone.utc) + return when.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + def _hand_clear(facts: Facts) -> tuple[bool, bool]: - """Whether a lead's removal of the label stands, and whether this run must record the head. + """Whether a lead's removal of the label stands, and whether this run must record it. The clear is bound to the head it was made at, so a push after it does not ride on it. - The first run that sees the removal records the head. Until then nothing is recorded. + A removal newer than the recorded one is a fresh clear at the current head, so a lead + who clears again after a push is not overruled. The run that first sees a removal + records its head and time. """ if facts.last_unlabel is None or not _on_roster(facts.last_unlabel[0], facts): return False, False - recorded = _stored(facts).get("cleared_head") - if recorded is None: + stored = _stored(facts) + recorded_at = stored.get("cleared_at") + # Both sides are fixed-width UTC strings, so string order is time order. + if recorded_at is None or _unlabel_time(facts) > recorded_at: return True, True - return recorded == facts.head_sha, False + return stored.get("cleared_head") == facts.head_sha, False def _engaged(facts: Facts, today: date) -> str | None: @@ -475,6 +497,11 @@ def _auto_merge_off(facts: Facts, queued: bool) -> bool: return queued or (not _on_roster(facts.author, facts) and bool(_roadmap_closes(facts))) +# Data line values stop at 200 characters. A bounded count of the lowest numbers keeps a +# body naming many issues from making the line unwritable. +MAX_DATA_ISSUES = 10 + + def _claims(facts: Facts) -> list[tuple[int, date]]: """Comments by the author on accepted open issues they do not hold, which may be claims.""" return sorted( @@ -492,7 +519,10 @@ def _hash_as_read_next(facts: Facts, cleared_head: str | None, today: date) -> s return facts_hash(replace(facts, bot_comment=comment), today) -def _data(facts: Facts, today: date, route: str | None, reasons: tuple[str, ...], cleared_head: str | None) -> dict[str, str]: +def _data( + facts: Facts, today: date, route: str | None, reasons: tuple[str, ...], cleared_head: str | None, + cleared_at: str | None, +) -> dict[str, str]: stored = _stored(facts) data: dict[str, str] = {} if route is not None: @@ -504,17 +534,19 @@ def _data(facts: Facts, today: date, route: str | None, reasons: tuple[str, ...] data["reason"] = reasons[0] issues = sorted({ref.number for ref in _accepted_issues(facts)}) if issues: - data["issues"] = ",".join(str(number) for number in issues) + data["issues"] = ",".join(str(number) for number in issues[:MAX_DATA_ISSUES]) # The queue clock runs from the first queue and survives re-renders, so the health # issue's day count is the time waited, not the time since the last edit. queued_before = stored.get("reason") not in (None, "cleared") and "since" in stored data["since"] = stored["since"] if queued_before else today.isoformat() claims = _claims(facts) if claims: - data["claimed"] = ",".join(f"{number}:{when.isoformat()}" for number, when in claims) + data["claimed"] = ",".join(f"{number}:{when.isoformat()}" for number, when in claims[:MAX_CLAIM_READS]) data["head"] = facts.head_sha if cleared_head: data["cleared_head"] = cleared_head + if cleared_at: + data["cleared_at"] = cleared_at data["facts"] = _hash_as_read_next(facts, cleared_head, today) return data @@ -550,11 +582,12 @@ def _enforced(facts: Facts, today: date) -> Decision: reasons: tuple[str, ...] = () else: route, reasons = None, _reasons(facts, today) - recorded, newly = stored.get("cleared_head"), False + recorded, recorded_at, newly = stored.get("cleared_head"), stored.get("cleared_at"), False if route == "hand": newly = _hand_clear(facts)[1] - recorded = facts.head_sha if newly else recorded - data = _data(facts, today, route, reasons, recorded) + if newly: + recorded, recorded_at = facts.head_sha, _unlabel_time(facts) + data = _data(facts, today, route, reasons, recorded, recorded_at) labeled = roadmap_model.NEEDS_TRIAGE in facts.labels decision = Decision( outcome="pass" if passed else "queue", @@ -567,7 +600,10 @@ def _enforced(facts: Facts, today: date) -> Decision: notices=(), ) if stored.get("facts") == data["facts"] and stored.get("reason") == data["reason"]: - return Decision(decision.outcome, route, reasons, "keep", None, stored, decision.disable_auto_merge, ()) + # The queue label is outside the hash, so a label that drifted from the recorded + # state is repaired here without re-rendering the comment. + drift = "set" if not passed and not labeled else "clear" if passed and labeled else "keep" + return Decision(decision.outcome, route, reasons, drift, None, stored, decision.disable_auto_merge, ()) # A pass on a pull request the bot never spoke on stays silent, unless the merger needs # the roadmap warning or a hand clear must be recorded. if passed and facts.bot_comment is None and not labeled and not _roadmap_closes(facts) and not newly: From 054c80577564e99c385b85823fa5b96976f5d887 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 20:37:30 -0600 Subject: [PATCH 07/31] Collect intake facts from the shared listings within a call budget Signed-off-by: rocklambros --- tests/fixtures/pr_intake/accepted_all.json | 1997 ++++++ tests/fixtures/pr_intake/comments.json | 1419 ++++ tests/fixtures/pr_intake/files_page1.json | 602 ++ tests/fixtures/pr_intake/files_page2.json | 74 + tests/fixtures/pr_intake/issue_comments.json | 942 +++ tests/fixtures/pr_intake/population.json | 5909 +++++++++++++++++ tests/fixtures/pr_intake/pull.json | 585 ++ tests/fixtures/pr_intake/pulls_closed.json | 3217 +++++++++ tests/fixtures/pr_intake/pulls_open.json | 3360 ++++++++++ tests/fixtures/pr_intake/record_population.py | 200 + tests/fixtures/pr_intake/reviews.json | 570 ++ tests/fixtures/pr_intake/scrub.py | 124 + tests/fixtures/pr_intake/timeline.json | 697 ++ tests/test_pr_intake.py | 337 +- tools/pr_intake.py | 398 +- 15 files changed, 20424 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/pr_intake/accepted_all.json create mode 100644 tests/fixtures/pr_intake/comments.json create mode 100644 tests/fixtures/pr_intake/files_page1.json create mode 100644 tests/fixtures/pr_intake/files_page2.json create mode 100644 tests/fixtures/pr_intake/issue_comments.json create mode 100644 tests/fixtures/pr_intake/population.json create mode 100644 tests/fixtures/pr_intake/pull.json create mode 100644 tests/fixtures/pr_intake/pulls_closed.json create mode 100644 tests/fixtures/pr_intake/pulls_open.json create mode 100644 tests/fixtures/pr_intake/record_population.py create mode 100644 tests/fixtures/pr_intake/reviews.json create mode 100644 tests/fixtures/pr_intake/scrub.py create mode 100644 tests/fixtures/pr_intake/timeline.json diff --git a/tests/fixtures/pr_intake/accepted_all.json b/tests/fixtures/pr_intake/accepted_all.json new file mode 100644 index 00000000..aa736d0e --- /dev/null +++ b/tests/fixtures/pr_intake/accepted_all.json @@ -0,0 +1,1997 @@ +[ + { + "active_lock_reason": null, + "assignee": null, + "assignees": [], + "author_association": "MEMBER", + "body": "scrubbed", + "closed_at": null, + "closed_by": null, + "comments": 20, + "comments_url": "https://api.github.com/scrubbed", + "created_at": "2026-09-10T13:53:47Z", + "events_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 5413068045, + "issue_dependencies_summary": { + "blocked_by": 0, + "blocking": 0, + "total_blocked_by": 0, + "total_blocking": 0 + }, + "issue_field_values": [], + "labels": [ + { + "archived_at": null, + "archived_by": null, + "color": "008672", + "default": true, + "description": "scrubbed", + "id": 10662663608, + "name": "help wanted", + "node_id": "LA_kwDOR_dibc8AAAACe4tVuA", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "0e8a16", + "default": false, + "description": "scrubbed", + "id": 12125610333, + "name": "scope:in-focus", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hXQ", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "0e8a16", + "default": false, + "description": "scrubbed", + "id": 12125610443, + "name": "status:accepted", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hyw", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "d93f0b", + "default": false, + "description": "scrubbed", + "id": 12125610650, + "name": "priority:P1", + "node_id": "LA_kwDOR_dibc8AAAAC0r4img", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "c5def5", + "default": false, + "description": "scrubbed", + "id": 12125610768, + "name": "workstream:coding-agents", + "node_id": "LA_kwDOR_dibc8AAAAC0r4jEA", + "url": "https://api.github.com/scrubbed" + } + ], + "labels_url": "https://api.github.com/scrubbed", + "locked": false, + "milestone": { + "closed_at": null, + "closed_issues": 0, + "created_at": "2026-10-05T08:09:22Z", + "creator": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "description": "scrubbed", + "due_on": "2026-12-31T00:00:00Z", + "html_url": "https://api.github.com/scrubbed", + "id": 18319001, + "labels_url": "https://api.github.com/scrubbed", + "node_id": "MI_kwDOR_dibc4BF4aZ", + "number": 11, + "open_issues": 4, + "state": "open", + "title": "scrubbed", + "updated_at": "2026-10-05T09:18:26Z", + "url": "https://api.github.com/scrubbed" + }, + "node_id": "I_kwDOR_dibc8AAAABQqTdDQ", + "number": 92, + "performed_via_github_app": null, + "pinned_comment": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "repository_url": "https://api.github.com/scrubbed", + "state": "open", + "state_reason": null, + "sub_issues_summary": { + "completed": 0, + "percent_completed": 0, + "total": 1 + }, + "timeline_url": "https://api.github.com/scrubbed", + "title": "scrubbed", + "type": null, + "updated_at": "2026-10-27T19:25:27Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "active_lock_reason": null, + "assignee": null, + "assignees": [], + "author_association": "MEMBER", + "body": "scrubbed", + "closed_at": null, + "closed_by": null, + "comments": 0, + "comments_url": "https://api.github.com/scrubbed", + "created_at": "2026-10-30T00:15:45Z", + "events_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 6110778161, + "issue_dependencies_summary": { + "blocked_by": 0, + "blocking": 0, + "total_blocked_by": 0, + "total_blocking": 0 + }, + "issue_field_values": [], + "labels": [ + { + "archived_at": null, + "archived_by": null, + "color": "a2eeef", + "default": false, + "description": "scrubbed", + "id": 10662663589, + "name": "type:proposal", + "node_id": "LA_kwDOR_dibc8AAAACe4tVpQ", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "0e8a16", + "default": false, + "description": "scrubbed", + "id": 12125610333, + "name": "scope:in-focus", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hXQ", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "0e8a16", + "default": false, + "description": "scrubbed", + "id": 12125610443, + "name": "status:accepted", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hyw", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "d93f0b", + "default": false, + "description": "scrubbed", + "id": 12125610650, + "name": "priority:P1", + "node_id": "LA_kwDOR_dibc8AAAAC0r4img", + "url": "https://api.github.com/scrubbed" + } + ], + "labels_url": "https://api.github.com/scrubbed", + "locked": false, + "milestone": { + "closed_at": null, + "closed_issues": 2, + "created_at": "2026-10-05T08:09:22Z", + "creator": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "description": "scrubbed", + "due_on": "2026-12-31T00:00:00Z", + "html_url": "https://api.github.com/scrubbed", + "id": 18319002, + "labels_url": "https://api.github.com/scrubbed", + "node_id": "MI_kwDOR_dibc4BF4aa", + "number": 12, + "open_issues": 6, + "state": "open", + "title": "scrubbed", + "updated_at": "2026-10-30T00:15:45Z", + "url": "https://api.github.com/scrubbed" + }, + "node_id": "I_kwDOR_dibc8AAAABbDsTMQ", + "number": 362, + "performed_via_github_app": null, + "pinned_comment": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "repository_url": "https://api.github.com/scrubbed", + "state": "open", + "state_reason": null, + "sub_issues_summary": { + "completed": 0, + "percent_completed": 0, + "total": 0 + }, + "timeline_url": "https://api.github.com/scrubbed", + "title": "scrubbed", + "type": null, + "updated_at": "2026-10-30T00:15:45Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "active_lock_reason": null, + "assignee": null, + "assignees": [], + "author_association": "MEMBER", + "body": "scrubbed", + "closed_at": null, + "closed_by": null, + "comments": 0, + "comments_url": "https://api.github.com/scrubbed", + "created_at": "2026-10-30T00:15:43Z", + "events_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 6110777842, + "issue_dependencies_summary": { + "blocked_by": 0, + "blocking": 0, + "total_blocked_by": 0, + "total_blocking": 0 + }, + "issue_field_values": [], + "labels": [ + { + "archived_at": null, + "archived_by": null, + "color": "0075ca", + "default": false, + "description": "scrubbed", + "id": 10662663574, + "name": "type:docs", + "node_id": "LA_kwDOR_dibc8AAAACe4tVlg", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "0e8a16", + "default": false, + "description": "scrubbed", + "id": 12125610333, + "name": "scope:in-focus", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hXQ", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "0e8a16", + "default": false, + "description": "scrubbed", + "id": 12125610443, + "name": "status:accepted", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hyw", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "fef2c0", + "default": false, + "description": "scrubbed", + "id": 12125610694, + "name": "priority:P2", + "node_id": "LA_kwDOR_dibc8AAAAC0r4ixg", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "c5def5", + "default": false, + "description": "scrubbed", + "id": 12292816264, + "name": "workstream:docs", + "node_id": "LA_kwDOR_dibc8AAAAC3LV9iA", + "url": "https://api.github.com/scrubbed" + } + ], + "labels_url": "https://api.github.com/scrubbed", + "locked": false, + "milestone": { + "closed_at": null, + "closed_issues": 9, + "created_at": "2026-10-05T08:09:20Z", + "creator": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "description": "scrubbed", + "due_on": "2026-12-31T00:00:00Z", + "html_url": "https://api.github.com/scrubbed", + "id": 18318997, + "labels_url": "https://api.github.com/scrubbed", + "node_id": "MI_kwDOR_dibc4BF4aV", + "number": 8, + "open_issues": 15, + "state": "open", + "title": "scrubbed", + "updated_at": "2026-10-30T00:15:43Z", + "url": "https://api.github.com/scrubbed" + }, + "node_id": "I_kwDOR_dibc8AAAABbDsR8g", + "number": 361, + "performed_via_github_app": null, + "pinned_comment": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "repository_url": "https://api.github.com/scrubbed", + "state": "open", + "state_reason": null, + "sub_issues_summary": { + "completed": 0, + "percent_completed": 0, + "total": 0 + }, + "timeline_url": "https://api.github.com/scrubbed", + "title": "scrubbed", + "type": null, + "updated_at": "2026-10-30T00:15:43Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "active_lock_reason": null, + "assignee": null, + "assignees": [], + "author_association": "COLLABORATOR", + "body": "scrubbed", + "closed_at": null, + "closed_by": null, + "comments": 0, + "comments_url": "https://api.github.com/scrubbed", + "created_at": "2026-10-28T12:58:01Z", + "events_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 6079381162, + "issue_dependencies_summary": { + "blocked_by": 0, + "blocking": 0, + "total_blocked_by": 0, + "total_blocking": 0 + }, + "issue_field_values": [], + "labels": [ + { + "archived_at": null, + "archived_by": null, + "color": "d73a4a", + "default": false, + "description": "scrubbed", + "id": 10662663566, + "name": "type:bug", + "node_id": "LA_kwDOR_dibc8AAAACe4tVjg", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "0e8a16", + "default": false, + "description": "scrubbed", + "id": 12125610333, + "name": "scope:in-focus", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hXQ", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "0e8a16", + "default": false, + "description": "scrubbed", + "id": 12125610443, + "name": "status:accepted", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hyw", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "d93f0b", + "default": false, + "description": "scrubbed", + "id": 12125610650, + "name": "priority:P1", + "node_id": "LA_kwDOR_dibc8AAAAC0r4img", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "c5def5", + "default": false, + "description": "scrubbed", + "id": 12292816092, + "name": "workstream:refimpl", + "node_id": "LA_kwDOR_dibc8AAAAC3LV83A", + "url": "https://api.github.com/scrubbed" + } + ], + "labels_url": "https://api.github.com/scrubbed", + "locked": false, + "milestone": { + "closed_at": null, + "closed_issues": 1, + "created_at": "2026-10-05T08:09:21Z", + "creator": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "description": "scrubbed", + "due_on": "2026-12-31T00:00:00Z", + "html_url": "https://api.github.com/scrubbed", + "id": 18318998, + "labels_url": "https://api.github.com/scrubbed", + "node_id": "MI_kwDOR_dibc4BF4aW", + "number": 9, + "open_issues": 24, + "state": "open", + "title": "scrubbed", + "updated_at": "2026-10-29T14:45:09Z", + "url": "https://api.github.com/scrubbed" + }, + "node_id": "I_kwDOR_dibc8AAAABalv-qg", + "number": 351, + "performed_via_github_app": null, + "pinned_comment": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "repository_url": "https://api.github.com/scrubbed", + "state": "open", + "state_reason": null, + "sub_issues_summary": { + "completed": 0, + "percent_completed": 0, + "total": 0 + }, + "timeline_url": "https://api.github.com/scrubbed", + "title": "scrubbed", + "type": null, + "updated_at": "2026-10-29T14:45:09Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-11", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "active_lock_reason": null, + "assignee": null, + "assignees": [], + "author_association": "MEMBER", + "body": "scrubbed", + "closed_at": null, + "closed_by": null, + "comments": 4, + "comments_url": "https://api.github.com/scrubbed", + "created_at": "2026-10-23T10:14:31Z", + "events_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 5995951336, + "issue_dependencies_summary": { + "blocked_by": 0, + "blocking": 0, + "total_blocked_by": 0, + "total_blocking": 0 + }, + "issue_field_values": [], + "labels": [ + { + "archived_at": null, + "archived_by": null, + "color": "1d76db", + "default": false, + "description": "scrubbed", + "id": 12125610266, + "name": "type:refimpl", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hGg", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "0e8a16", + "default": false, + "description": "scrubbed", + "id": 12125610333, + "name": "scope:in-focus", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hXQ", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "0e8a16", + "default": false, + "description": "scrubbed", + "id": 12125610443, + "name": "status:accepted", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hyw", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "fef2c0", + "default": false, + "description": "scrubbed", + "id": 12125610694, + "name": "priority:P2", + "node_id": "LA_kwDOR_dibc8AAAAC0r4ixg", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "c5def5", + "default": false, + "description": "scrubbed", + "id": 12292816092, + "name": "workstream:refimpl", + "node_id": "LA_kwDOR_dibc8AAAAC3LV83A", + "url": "https://api.github.com/scrubbed" + } + ], + "labels_url": "https://api.github.com/scrubbed", + "locked": false, + "milestone": { + "closed_at": null, + "closed_issues": 4, + "created_at": "2026-10-05T08:09:21Z", + "creator": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "description": "scrubbed", + "due_on": "2026-12-31T00:00:00Z", + "html_url": "https://api.github.com/scrubbed", + "id": 18318999, + "labels_url": "https://api.github.com/scrubbed", + "node_id": "MI_kwDOR_dibc4BF4aX", + "number": 10, + "open_issues": 9, + "state": "open", + "title": "scrubbed", + "updated_at": "2026-10-23T10:24:48Z", + "url": "https://api.github.com/scrubbed" + }, + "node_id": "I_kwDOR_dibc8AAAABZWL06A", + "number": 322, + "performed_via_github_app": null, + "pinned_comment": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "repository_url": "https://api.github.com/scrubbed", + "state": "open", + "state_reason": null, + "sub_issues_summary": { + "completed": 0, + "percent_completed": 0, + "total": 0 + }, + "timeline_url": "https://api.github.com/scrubbed", + "title": "scrubbed", + "type": null, + "updated_at": "2026-10-29T20:47:26Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "active_lock_reason": null, + "assignee": null, + "assignees": [], + "author_association": "MEMBER", + "body": "scrubbed", + "closed_at": null, + "closed_by": null, + "comments": 1, + "comments_url": "https://api.github.com/scrubbed", + "created_at": "2026-10-15T18:28:43Z", + "events_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 5871729887, + "issue_dependencies_summary": { + "blocked_by": 0, + "blocking": 0, + "total_blocked_by": 0, + "total_blocking": 0 + }, + "issue_field_values": [], + "labels": [ + { + "archived_at": null, + "archived_by": null, + "color": "1d76db", + "default": false, + "description": "scrubbed", + "id": 12125610266, + "name": "type:refimpl", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hGg", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "0e8a16", + "default": false, + "description": "scrubbed", + "id": 12125610333, + "name": "scope:in-focus", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hXQ", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "0e8a16", + "default": false, + "description": "scrubbed", + "id": 12125610443, + "name": "status:accepted", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hyw", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "fef2c0", + "default": false, + "description": "scrubbed", + "id": 12125610694, + "name": "priority:P2", + "node_id": "LA_kwDOR_dibc8AAAAC0r4ixg", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "c5def5", + "default": false, + "description": "scrubbed", + "id": 12292816092, + "name": "workstream:refimpl", + "node_id": "LA_kwDOR_dibc8AAAAC3LV83A", + "url": "https://api.github.com/scrubbed" + } + ], + "labels_url": "https://api.github.com/scrubbed", + "locked": false, + "milestone": { + "closed_at": null, + "closed_issues": 4, + "created_at": "2026-10-05T08:09:21Z", + "creator": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "description": "scrubbed", + "due_on": "2026-12-31T00:00:00Z", + "html_url": "https://api.github.com/scrubbed", + "id": 18318999, + "labels_url": "https://api.github.com/scrubbed", + "node_id": "MI_kwDOR_dibc4BF4aX", + "number": 10, + "open_issues": 9, + "state": "open", + "title": "scrubbed", + "updated_at": "2026-10-23T10:24:48Z", + "url": "https://api.github.com/scrubbed" + }, + "node_id": "I_kwDOR_dibc8AAAABXft83w", + "number": 284, + "parent_issue_url": "https://api.github.com/scrubbed", + "performed_via_github_app": null, + "pinned_comment": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "repository_url": "https://api.github.com/scrubbed", + "state": "open", + "state_reason": null, + "sub_issues_summary": { + "completed": 0, + "percent_completed": 0, + "total": 0 + }, + "timeline_url": "https://api.github.com/scrubbed", + "title": "scrubbed", + "type": null, + "updated_at": "2026-10-16T19:27:36Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "active_lock_reason": null, + "assignee": null, + "assignees": [], + "author_association": "MEMBER", + "body": "scrubbed", + "closed_at": null, + "closed_by": null, + "comments": 2, + "comments_url": "https://api.github.com/scrubbed", + "created_at": "2026-10-15T18:28:40Z", + "events_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 5871729325, + "issue_dependencies_summary": { + "blocked_by": 0, + "blocking": 0, + "total_blocked_by": 0, + "total_blocking": 0 + }, + "issue_field_values": [], + "labels": [ + { + "archived_at": null, + "archived_by": null, + "color": "1d76db", + "default": false, + "description": "scrubbed", + "id": 12125610266, + "name": "type:refimpl", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hGg", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "0e8a16", + "default": false, + "description": "scrubbed", + "id": 12125610333, + "name": "scope:in-focus", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hXQ", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "0e8a16", + "default": false, + "description": "scrubbed", + "id": 12125610443, + "name": "status:accepted", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hyw", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "fef2c0", + "default": false, + "description": "scrubbed", + "id": 12125610694, + "name": "priority:P2", + "node_id": "LA_kwDOR_dibc8AAAAC0r4ixg", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "c5def5", + "default": false, + "description": "scrubbed", + "id": 12292816092, + "name": "workstream:refimpl", + "node_id": "LA_kwDOR_dibc8AAAAC3LV83A", + "url": "https://api.github.com/scrubbed" + } + ], + "labels_url": "https://api.github.com/scrubbed", + "locked": false, + "milestone": { + "closed_at": null, + "closed_issues": 4, + "created_at": "2026-10-05T08:09:21Z", + "creator": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "description": "scrubbed", + "due_on": "2026-12-31T00:00:00Z", + "html_url": "https://api.github.com/scrubbed", + "id": 18318999, + "labels_url": "https://api.github.com/scrubbed", + "node_id": "MI_kwDOR_dibc4BF4aX", + "number": 10, + "open_issues": 9, + "state": "open", + "title": "scrubbed", + "updated_at": "2026-10-23T10:24:48Z", + "url": "https://api.github.com/scrubbed" + }, + "node_id": "I_kwDOR_dibc8AAAABXft6rQ", + "number": 283, + "parent_issue_url": "https://api.github.com/scrubbed", + "performed_via_github_app": null, + "pinned_comment": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "repository_url": "https://api.github.com/scrubbed", + "state": "open", + "state_reason": null, + "sub_issues_summary": { + "completed": 0, + "percent_completed": 0, + "total": 0 + }, + "timeline_url": "https://api.github.com/scrubbed", + "title": "scrubbed", + "type": null, + "updated_at": "2026-10-17T19:19:50Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "active_lock_reason": null, + "assignee": null, + "assignees": [], + "author_association": "MEMBER", + "body": "scrubbed", + "closed_at": null, + "closed_by": null, + "comments": 1, + "comments_url": "https://api.github.com/scrubbed", + "created_at": "2026-10-15T18:28:38Z", + "events_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 5871728912, + "issue_dependencies_summary": { + "blocked_by": 0, + "blocking": 0, + "total_blocked_by": 0, + "total_blocking": 0 + }, + "issue_field_values": [], + "labels": [ + { + "archived_at": null, + "archived_by": null, + "color": "1d76db", + "default": false, + "description": "scrubbed", + "id": 12125610266, + "name": "type:refimpl", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hGg", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "0e8a16", + "default": false, + "description": "scrubbed", + "id": 12125610333, + "name": "scope:in-focus", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hXQ", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "0e8a16", + "default": false, + "description": "scrubbed", + "id": 12125610443, + "name": "status:accepted", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hyw", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "fef2c0", + "default": false, + "description": "scrubbed", + "id": 12125610694, + "name": "priority:P2", + "node_id": "LA_kwDOR_dibc8AAAAC0r4ixg", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "c5def5", + "default": false, + "description": "scrubbed", + "id": 12292816092, + "name": "workstream:refimpl", + "node_id": "LA_kwDOR_dibc8AAAAC3LV83A", + "url": "https://api.github.com/scrubbed" + } + ], + "labels_url": "https://api.github.com/scrubbed", + "locked": false, + "milestone": { + "closed_at": null, + "closed_issues": 4, + "created_at": "2026-10-05T08:09:21Z", + "creator": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "description": "scrubbed", + "due_on": "2026-12-31T00:00:00Z", + "html_url": "https://api.github.com/scrubbed", + "id": 18318999, + "labels_url": "https://api.github.com/scrubbed", + "node_id": "MI_kwDOR_dibc4BF4aX", + "number": 10, + "open_issues": 9, + "state": "open", + "title": "scrubbed", + "updated_at": "2026-10-23T10:24:48Z", + "url": "https://api.github.com/scrubbed" + }, + "node_id": "I_kwDOR_dibc8AAAABXft5EA", + "number": 282, + "parent_issue_url": "https://api.github.com/scrubbed", + "performed_via_github_app": null, + "pinned_comment": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "repository_url": "https://api.github.com/scrubbed", + "state": "open", + "state_reason": null, + "sub_issues_summary": { + "completed": 0, + "percent_completed": 0, + "total": 0 + }, + "timeline_url": "https://api.github.com/scrubbed", + "title": "scrubbed", + "type": null, + "updated_at": "2026-10-16T19:27:31Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "active_lock_reason": null, + "assignee": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "assignees": [ + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + ], + "author_association": "COLLABORATOR", + "body": "scrubbed", + "closed_at": "2026-10-15T17:36:42Z", + "closed_by": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "comments": 0, + "comments_url": "https://api.github.com/scrubbed", + "created_at": "2026-10-15T09:23:28Z", + "events_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 5863513283, + "issue_dependencies_summary": { + "blocked_by": 0, + "blocking": 0, + "total_blocked_by": 0, + "total_blocking": 0 + }, + "issue_field_values": [], + "labels": [ + { + "archived_at": null, + "archived_by": null, + "color": "1d76db", + "default": false, + "description": "scrubbed", + "id": 12125610266, + "name": "type:refimpl", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hGg", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "0e8a16", + "default": false, + "description": "scrubbed", + "id": 12125610333, + "name": "scope:in-focus", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hXQ", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "0e8a16", + "default": false, + "description": "scrubbed", + "id": 12125610443, + "name": "status:accepted", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hyw", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "d93f0b", + "default": false, + "description": "scrubbed", + "id": 12125610650, + "name": "priority:P1", + "node_id": "LA_kwDOR_dibc8AAAAC0r4img", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "c5def5", + "default": false, + "description": "scrubbed", + "id": 12292816092, + "name": "workstream:refimpl", + "node_id": "LA_kwDOR_dibc8AAAAC3LV83A", + "url": "https://api.github.com/scrubbed" + } + ], + "labels_url": "https://api.github.com/scrubbed", + "locked": false, + "milestone": { + "closed_at": null, + "closed_issues": 4, + "created_at": "2026-10-05T08:09:21Z", + "creator": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "description": "scrubbed", + "due_on": "2026-12-31T00:00:00Z", + "html_url": "https://api.github.com/scrubbed", + "id": 18318999, + "labels_url": "https://api.github.com/scrubbed", + "node_id": "MI_kwDOR_dibc4BF4aX", + "number": 10, + "open_issues": 9, + "state": "open", + "title": "scrubbed", + "updated_at": "2026-10-23T10:24:48Z", + "url": "https://api.github.com/scrubbed" + }, + "node_id": "I_kwDOR_dibc8AAAABXX4cww", + "number": 276, + "performed_via_github_app": null, + "pinned_comment": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "repository_url": "https://api.github.com/scrubbed", + "state": "closed", + "state_reason": "completed", + "sub_issues_summary": { + "completed": 0, + "percent_completed": 0, + "total": 0 + }, + "timeline_url": "https://api.github.com/scrubbed", + "title": "scrubbed", + "type": null, + "updated_at": "2026-10-15T17:36:42Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-11", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "active_lock_reason": null, + "assignee": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "assignees": [ + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + ], + "author_association": "MEMBER", + "body": "scrubbed", + "closed_at": "2026-10-15T17:36:42Z", + "closed_by": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "comments": 0, + "comments_url": "https://api.github.com/scrubbed", + "created_at": "2026-10-12T16:25:25Z", + "events_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 5817043616, + "issue_dependencies_summary": { + "blocked_by": 0, + "blocking": 0, + "total_blocked_by": 0, + "total_blocking": 0 + }, + "issue_field_values": [], + "labels": [ + { + "archived_at": null, + "archived_by": null, + "color": "1d76db", + "default": false, + "description": "scrubbed", + "id": 12125610266, + "name": "type:refimpl", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hGg", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "0e8a16", + "default": false, + "description": "scrubbed", + "id": 12125610333, + "name": "scope:in-focus", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hXQ", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "0e8a16", + "default": false, + "description": "scrubbed", + "id": 12125610443, + "name": "status:accepted", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hyw", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "d93f0b", + "default": false, + "description": "scrubbed", + "id": 12125610650, + "name": "priority:P1", + "node_id": "LA_kwDOR_dibc8AAAAC0r4img", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "c5def5", + "default": false, + "description": "scrubbed", + "id": 12125610768, + "name": "workstream:coding-agents", + "node_id": "LA_kwDOR_dibc8AAAAC0r4jEA", + "url": "https://api.github.com/scrubbed" + } + ], + "labels_url": "https://api.github.com/scrubbed", + "locked": false, + "milestone": { + "closed_at": null, + "closed_issues": 4, + "created_at": "2026-10-05T08:09:21Z", + "creator": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "description": "scrubbed", + "due_on": "2026-12-31T00:00:00Z", + "html_url": "https://api.github.com/scrubbed", + "id": 18318999, + "labels_url": "https://api.github.com/scrubbed", + "node_id": "MI_kwDOR_dibc4BF4aX", + "number": 10, + "open_issues": 9, + "state": "open", + "title": "scrubbed", + "updated_at": "2026-10-23T10:24:48Z", + "url": "https://api.github.com/scrubbed" + }, + "node_id": "I_kwDOR_dibc8AAAABWrkKoA", + "number": 266, + "performed_via_github_app": null, + "pinned_comment": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "repository_url": "https://api.github.com/scrubbed", + "state": "closed", + "state_reason": "completed", + "sub_issues_summary": { + "completed": 0, + "percent_completed": 0, + "total": 0 + }, + "timeline_url": "https://api.github.com/scrubbed", + "title": "scrubbed", + "type": null, + "updated_at": "2026-10-15T17:36:42Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "active_lock_reason": null, + "assignee": null, + "assignees": [], + "author_association": "MEMBER", + "body": "scrubbed", + "closed_at": "2026-10-15T11:03:30Z", + "closed_by": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "comments": 0, + "comments_url": "https://api.github.com/scrubbed", + "created_at": "2026-10-12T12:21:08Z", + "events_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 5813622975, + "issue_dependencies_summary": { + "blocked_by": 0, + "blocking": 0, + "total_blocked_by": 0, + "total_blocking": 0 + }, + "issue_field_values": [], + "labels": [ + { + "archived_at": null, + "archived_by": null, + "color": "d73a4a", + "default": false, + "description": "scrubbed", + "id": 10662663566, + "name": "type:bug", + "node_id": "LA_kwDOR_dibc8AAAACe4tVjg", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "0e8a16", + "default": false, + "description": "scrubbed", + "id": 12125610333, + "name": "scope:in-focus", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hXQ", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "0e8a16", + "default": false, + "description": "scrubbed", + "id": 12125610443, + "name": "status:accepted", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hyw", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "fef2c0", + "default": false, + "description": "scrubbed", + "id": 12125610694, + "name": "priority:P2", + "node_id": "LA_kwDOR_dibc8AAAAC0r4ixg", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "c5def5", + "default": false, + "description": "scrubbed", + "id": 12125610746, + "name": "workstream:spec", + "node_id": "LA_kwDOR_dibc8AAAAC0r4i-g", + "url": "https://api.github.com/scrubbed" + } + ], + "labels_url": "https://api.github.com/scrubbed", + "locked": false, + "milestone": { + "closed_at": null, + "closed_issues": 9, + "created_at": "2026-10-05T08:09:20Z", + "creator": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "description": "scrubbed", + "due_on": "2026-12-31T00:00:00Z", + "html_url": "https://api.github.com/scrubbed", + "id": 18318997, + "labels_url": "https://api.github.com/scrubbed", + "node_id": "MI_kwDOR_dibc4BF4aV", + "number": 8, + "open_issues": 15, + "state": "open", + "title": "scrubbed", + "updated_at": "2026-10-30T00:15:43Z", + "url": "https://api.github.com/scrubbed" + }, + "node_id": "I_kwDOR_dibc8AAAABWoTYvw", + "number": 263, + "performed_via_github_app": null, + "pinned_comment": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "repository_url": "https://api.github.com/scrubbed", + "state": "closed", + "state_reason": "completed", + "sub_issues_summary": { + "completed": 0, + "percent_completed": 0, + "total": 0 + }, + "timeline_url": "https://api.github.com/scrubbed", + "title": "scrubbed", + "type": null, + "updated_at": "2026-10-15T11:03:30Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + } +] diff --git a/tests/fixtures/pr_intake/comments.json b/tests/fixtures/pr_intake/comments.json new file mode 100644 index 00000000..555b996f --- /dev/null +++ b/tests/fixtures/pr_intake/comments.json @@ -0,0 +1,1419 @@ +[ + { + "author_association": "NONE", + "body": "\n\n\nscrubbed", + "created_at": "2026-10-07T10:25:35Z", + "html_url": "https://api.github.com/scrubbed", + "id": 910001, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABZ8W5hg", + "performed_via_github_app": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-07T10:25:35Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-15", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "Thanks for this. It is queued rather than ignored. scrubbed", + "created_at": "2026-10-08T11:23:02Z", + "html_url": "https://api.github.com/scrubbed", + "id": 910002, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABaSGK0g", + "performed_via_github_app": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-08T11:23:02Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-16", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "Thanks for this. It is queued rather than ignored.\n\nscrubbed", + "created_at": "2026-10-29T07:57:28Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6491205507, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABgufvgw", + "performed_via_github_app": { + "client_id": "Iv1.05c79e9ad1f6bdfa", + "created_at": "2018-07-30T09:30:17Z", + "description": "scrubbed", + "events": [ + "branch_protection_rule", + "check_run", + "check_suite", + "create", + "delete", + "deployment", + "deployment_status", + "discussion", + "discussion_comment", + "fork", + "gollum", + "issues", + "issue_comment", + "label", + "merge_group", + "milestone", + "page_build", + "public", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "push", + "registry_package", + "release", + "repository", + "repository_dispatch", + "status", + "watch", + "workflow_dispatch", + "workflow_run" + ], + "external_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 15368, + "name": "scrubbed", + "node_id": "MDM6QXBwMTUzNjg=", + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-14", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "permissions": { + "actions": "write", + "administration": "read", + "artifact_metadata": "write", + "attestations": "write", + "checks": "write", + "code_quality": "write", + "contents": "write", + "copilot_requests": "write", + "deployments": "write", + "discussions": "write", + "drives": "write", + "issues": "write", + "merge_queues": "write", + "metadata": "read", + "models": "read", + "packages": "write", + "pages": "write", + "pull_requests": "write", + "repository_hooks": "write", + "repository_projects": "write", + "security_events": "write", + "statuses": "write", + "vulnerability_alerts": "read" + }, + "slug": "scrubbed", + "updated_at": "2026-06-18T16:17:48Z" + }, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-29T07:57:28Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "github-actions[bot]", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Bot", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": " scrubbed", + "created_at": "2026-10-17T12:39:14Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6233977506, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABc5Lyog", + "performed_via_github_app": { + "client_id": "Iv1.05c79e9ad1f6bdfa", + "created_at": "2018-07-30T09:30:17Z", + "description": "scrubbed", + "events": [ + "branch_protection_rule", + "check_run", + "check_suite", + "create", + "delete", + "deployment", + "deployment_status", + "discussion", + "discussion_comment", + "fork", + "gollum", + "issues", + "issue_comment", + "label", + "merge_group", + "milestone", + "page_build", + "public", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "push", + "registry_package", + "release", + "repository", + "repository_dispatch", + "status", + "watch", + "workflow_dispatch", + "workflow_run" + ], + "external_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 15368, + "name": "scrubbed", + "node_id": "MDM6QXBwMTUzNjg=", + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-14", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "permissions": { + "actions": "write", + "administration": "read", + "artifact_metadata": "write", + "attestations": "write", + "checks": "write", + "code_quality": "write", + "contents": "write", + "copilot_requests": "write", + "deployments": "write", + "discussions": "write", + "drives": "write", + "issues": "write", + "merge_queues": "write", + "metadata": "read", + "models": "read", + "packages": "write", + "pages": "write", + "pull_requests": "write", + "repository_hooks": "write", + "repository_projects": "write", + "security_events": "write", + "statuses": "write", + "vulnerability_alerts": "read" + }, + "slug": "scrubbed", + "updated_at": "2026-06-18T16:17:48Z" + }, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-17T12:39:14Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "github-actions[bot]", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Bot", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-07T10:25:35Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6035978630, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABZ8W5hg", + "performed_via_github_app": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-07T10:25:35Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-15", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-08T11:23:02Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6058773202, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABaSGK0g", + "performed_via_github_app": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-08T11:23:02Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-08T18:43:29Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6066690824, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABaZpbCA", + "performed_via_github_app": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-08T18:43:29Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-16", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "MEMBER", + "body": "scrubbed", + "created_at": "2026-10-12T02:28:59Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6116670104, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABbJT6mA", + "performed_via_github_app": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-12T02:28:59Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-12T02:37:56Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6116750294, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABbJYz1g", + "performed_via_github_app": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-12T02:37:56Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-12T11:23:17Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6123273443, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABbPm84w", + "performed_via_github_app": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-12T11:23:17Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-13T02:38:33Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6136988466, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABbcsDMg", + "performed_via_github_app": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-13T02:38:33Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-15", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-13T02:48:25Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6137100348, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABbcy4PA", + "performed_via_github_app": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-13T02:48:25Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-14T18:33:34Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6175521293, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABcBb6DQ", + "performed_via_github_app": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-14T18:33:34Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-16T19:19:41Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6222962454, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABcurfFg", + "performed_via_github_app": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-16T19:19:41Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-17T05:27:10Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6230175083, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABc1jtaw", + "performed_via_github_app": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-17T05:27:10Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "MEMBER", + "body": "scrubbed", + "created_at": "2026-10-20T20:05:50Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6300330933, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABd4drtQ", + "performed_via_github_app": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-20T20:06:03Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-21T04:18:30Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6307929248, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABd_tcoA", + "performed_via_github_app": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-21T04:18:30Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "MEMBER", + "body": "scrubbed", + "created_at": "2026-10-22T10:05:39Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6338131024, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABecg0UA", + "performed_via_github_app": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-22T10:05:39Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-22T15:45:18Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6344912138, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABei-tCg", + "performed_via_github_app": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-22T15:45:18Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-26T04:42:28Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6408405633, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABffiCgQ", + "performed_via_github_app": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-26T04:42:28Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-15", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-26T14:29:06Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6418534986, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABfpMSSg", + "performed_via_github_app": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-26T14:29:06Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-28T19:27:08Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6478530066, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABgiaGEg", + "performed_via_github_app": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-28T19:27:08Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-28T19:30:07Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6478590722, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABgidzAg", + "performed_via_github_app": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-28T19:30:07Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-29T11:51:01Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6495881051, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABgy9HWw", + "performed_via_github_app": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-29T11:51:01Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-17", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "\n\n\nscrubbed", + "created_at": "2026-10-29T12:00:00Z", + "html_url": "https://api.github.com/scrubbed", + "id": 910003, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABgufvgw", + "performed_via_github_app": { + "client_id": "Iv1.05c79e9ad1f6bdfa", + "created_at": "2018-07-30T09:30:17Z", + "description": "scrubbed", + "events": [ + "branch_protection_rule", + "check_run", + "check_suite", + "create", + "delete", + "deployment", + "deployment_status", + "discussion", + "discussion_comment", + "fork", + "gollum", + "issues", + "issue_comment", + "label", + "merge_group", + "milestone", + "page_build", + "public", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "push", + "registry_package", + "release", + "repository", + "repository_dispatch", + "status", + "watch", + "workflow_dispatch", + "workflow_run" + ], + "external_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 15368, + "name": "scrubbed", + "node_id": "MDM6QXBwMTUzNjg=", + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-14", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "permissions": { + "actions": "write", + "administration": "read", + "artifact_metadata": "write", + "attestations": "write", + "checks": "write", + "code_quality": "write", + "contents": "write", + "copilot_requests": "write", + "deployments": "write", + "discussions": "write", + "drives": "write", + "issues": "write", + "merge_queues": "write", + "metadata": "read", + "models": "read", + "packages": "write", + "pages": "write", + "pull_requests": "write", + "repository_hooks": "write", + "repository_projects": "write", + "security_events": "write", + "statuses": "write", + "vulnerability_alerts": "read" + }, + "slug": "scrubbed", + "updated_at": "2026-06-18T16:17:48Z" + }, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-29T12:00:00Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "github-actions[bot]", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Bot", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + } +] diff --git a/tests/fixtures/pr_intake/files_page1.json b/tests/fixtures/pr_intake/files_page1.json new file mode 100644 index 00000000..29288e9e --- /dev/null +++ b/tests/fixtures/pr_intake/files_page1.json @@ -0,0 +1,602 @@ +[ + { + "additions": 18, + "blob_url": "https://api.github.com/scrubbed", + "changes": 24, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 6, + "filename": "docs/spec/trace/extend_opentelemetry.md", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "8115812c57637462a997cdf06a0141ddc72b1750", + "status": "modified" + }, + { + "additions": 56, + "blob_url": "https://api.github.com/scrubbed", + "changes": 91, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 35, + "filename": "reference-implementations/agt/README.md", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "f13dc628b8f00338980df1d263b2017debab92ba", + "status": "modified" + }, + { + "additions": 3, + "blob_url": "https://api.github.com/scrubbed", + "changes": 4, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 1, + "filename": "reference-implementations/agt/hosts/claude-code/README.md", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "67bd32eec3498a308357547501eb69ed8c9400f9", + "status": "modified" + }, + { + "additions": 14, + "blob_url": "https://api.github.com/scrubbed", + "changes": 25, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 11, + "filename": "reference-implementations/agt/hosts/claude-code/acs-hook.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "9a6f0d9f3910339aa2ff3b62f26abfc4d808b888", + "status": "modified" + }, + { + "additions": 85, + "blob_url": "https://api.github.com/scrubbed", + "changes": 85, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 0, + "filename": "reference-implementations/agt/hosts/claude-code/test/posture.test.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "2bf8425549c9398e3040de11de51469b14e5e782", + "status": "modified" + }, + { + "additions": 3, + "blob_url": "https://api.github.com/scrubbed", + "changes": 4, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 1, + "filename": "reference-implementations/agt/hosts/opencode/README.md", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "d006282fadd66d9b871ba16408226606d3655325", + "status": "modified" + }, + { + "additions": 35, + "blob_url": "https://api.github.com/scrubbed", + "changes": 45, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 10, + "filename": "reference-implementations/agt/hosts/opencode/acs-plugin.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "c64b7af5694654cdca9986a940bf39c7fbe64d07", + "status": "modified" + }, + { + "additions": 21, + "blob_url": "https://api.github.com/scrubbed", + "changes": 21, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 0, + "filename": "reference-implementations/agt/hosts/opencode/test/acs-plugin.test.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "44a1382655712e696c353d5e6bb46369096f6155", + "status": "modified" + }, + { + "additions": 5, + "blob_url": "https://api.github.com/scrubbed", + "changes": 7, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 2, + "filename": "reference-implementations/agt/hosts/opencode/test/request-gate.test.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "f3c72bb5c5984b47c301acfed1e39d3a4f15b714", + "status": "modified" + }, + { + "additions": 56, + "blob_url": "https://api.github.com/scrubbed", + "changes": 139, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 83, + "filename": "reference-implementations/agt/packages/conformance/src/failure-domains.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "bd5eabca509fc7320568a57d4148a1be0bc8a765", + "status": "modified" + }, + { + "additions": 2, + "blob_url": "https://api.github.com/scrubbed", + "changes": 3, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 1, + "filename": "reference-implementations/agt/packages/conformance/test/failure-domains.test.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "e8f3ea3054de403469aa3eb548dfe031479a13f2", + "status": "modified" + }, + { + "additions": 26, + "blob_url": "https://api.github.com/scrubbed", + "changes": 41, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 15, + "filename": "reference-implementations/agt/packages/guardian/README.md", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "5eb1074b5fdc3af14231c101173c8c831bacdd60", + "status": "modified" + }, + { + "additions": 14, + "blob_url": "https://api.github.com/scrubbed", + "changes": 15, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 1, + "filename": "reference-implementations/agt/packages/guardian/src/acs-result.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "c389d08c416dc240c169be3eced5edf1d2f2a655", + "status": "modified" + }, + { + "additions": 8, + "blob_url": "https://api.github.com/scrubbed", + "changes": 8, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 0, + "filename": "reference-implementations/agt/packages/guardian/src/assemble-snapshot.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "6729d92168448664888110f60072c38f69469bfe", + "status": "modified" + }, + { + "additions": 35, + "blob_url": "https://api.github.com/scrubbed", + "changes": 61, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 26, + "filename": "reference-implementations/agt/packages/guardian/src/handshake.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "8933a309323c4d5c26ac8694a6ec8473bf3243e8", + "status": "modified" + }, + { + "additions": 9, + "blob_url": "https://api.github.com/scrubbed", + "changes": 9, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 0, + "filename": "reference-implementations/agt/packages/guardian/src/index.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "0a0326862a9d76baade53ee02d66240e13b3147b", + "status": "modified" + }, + { + "additions": 23, + "blob_url": "https://api.github.com/scrubbed", + "changes": 27, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 4, + "filename": "reference-implementations/agt/packages/guardian/src/main.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "7ce41f50294ef9c0c8315fcc47e044ffc145c696", + "status": "modified" + }, + { + "additions": 27, + "blob_url": "https://api.github.com/scrubbed", + "changes": 28, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 1, + "filename": "reference-implementations/agt/packages/guardian/src/map-verdict.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "9c45398a338b05dbf63abdecde7da021cd90aea3", + "status": "modified" + }, + { + "additions": 4, + "blob_url": "https://api.github.com/scrubbed", + "changes": 5, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 1, + "filename": "reference-implementations/agt/packages/guardian/src/otel-mapping.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "08a3ee302e837b149e9034e16a2d02f813f0efc5", + "status": "modified" + }, + { + "additions": 3, + "blob_url": "https://api.github.com/scrubbed", + "changes": 7, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 4, + "filename": "reference-implementations/agt/packages/guardian/src/otel-trace-exporter.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "8b649be4652e324616f2f15cd12e95a060e3738f", + "status": "modified" + }, + { + "additions": 543, + "blob_url": "https://api.github.com/scrubbed", + "changes": 596, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 53, + "filename": "reference-implementations/agt/packages/guardian/src/server.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "98e0c3209d22c3d1ccbec2bc3530cdd8ceec7dea", + "status": "modified" + }, + { + "additions": 2, + "blob_url": "https://api.github.com/scrubbed", + "changes": 2, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 0, + "filename": "reference-implementations/agt/packages/guardian/src/session-context-store.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "a0be6bae0c2b2c56a4f23a75a5cc4694ef54657e", + "status": "modified" + }, + { + "additions": 32, + "blob_url": "https://api.github.com/scrubbed", + "changes": 55, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 23, + "filename": "reference-implementations/agt/packages/guardian/src/session-context.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "706834d11ff9a6855866998cc517033acd024579", + "status": "modified" + }, + { + "additions": 30, + "blob_url": "https://api.github.com/scrubbed", + "changes": 36, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 6, + "filename": "reference-implementations/agt/packages/guardian/src/validate-envelope.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "6fbf024e7ba6f931c8c99f24ca5900efdc1bab02", + "status": "modified" + }, + { + "additions": 103, + "blob_url": "https://api.github.com/scrubbed", + "changes": 103, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 0, + "filename": "reference-implementations/agt/packages/guardian/src/verify-signature.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "795e9ba9de1a6b4214d26ac00b214c446cae5c45", + "status": "added" + }, + { + "additions": 1, + "blob_url": "https://api.github.com/scrubbed", + "changes": 1, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 0, + "filename": "reference-implementations/agt/packages/guardian/test/deny-on-invalid-envelope.test.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "e617e897c4b4703d15d5e4489b9817e07a4e371a", + "status": "modified" + }, + { + "additions": 14, + "blob_url": "https://api.github.com/scrubbed", + "changes": 17, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 3, + "filename": "reference-implementations/agt/packages/guardian/test/envelope-log-sink-wiring.test.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "6284283aab173b4a7090c0abab423cd675e9b688", + "status": "modified" + }, + { + "additions": 17, + "blob_url": "https://api.github.com/scrubbed", + "changes": 18, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 1, + "filename": "reference-implementations/agt/packages/guardian/test/handshake.test.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "dc38b1c92e4ea05d1162f1eb7889de8f8f909413", + "status": "modified" + }, + { + "additions": 8, + "blob_url": "https://api.github.com/scrubbed", + "changes": 10, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 2, + "filename": "reference-implementations/agt/packages/guardian/test/map-verdict.test.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "307a2c0067ab4a34ae675d4e99751eb7c64b3022", + "status": "modified" + }, + { + "additions": 101, + "blob_url": "https://api.github.com/scrubbed", + "changes": 109, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 8, + "filename": "reference-implementations/agt/packages/guardian/test/otel-trace-exporter.test.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "3dcc95a68bd2af46f7c3063b219b3743aa798e18", + "status": "modified" + }, + { + "additions": 27, + "blob_url": "https://api.github.com/scrubbed", + "changes": 31, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 4, + "filename": "reference-implementations/agt/packages/guardian/test/request-id-ref.test.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "145630eb5569b18ec1dec8aad91c962f72b35f9a", + "status": "modified" + }, + { + "additions": 498, + "blob_url": "https://api.github.com/scrubbed", + "changes": 520, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 22, + "filename": "reference-implementations/agt/packages/guardian/test/server.test.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "d2c774263cb08b70425d2a965d4f6bc834714d87", + "status": "modified" + }, + { + "additions": 34, + "blob_url": "https://api.github.com/scrubbed", + "changes": 40, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 6, + "filename": "reference-implementations/agt/packages/guardian/test/session-context.test.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "02de428c9533c6e92aec91afbc243f0fb378342e", + "status": "modified" + }, + { + "additions": 7, + "blob_url": "https://api.github.com/scrubbed", + "changes": 8, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 1, + "filename": "reference-implementations/agt/packages/guardian/test/validate-envelope.test.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "f71deb821eed3ad0e31a5277c5a253c41a91a52f", + "status": "modified" + }, + { + "additions": 1, + "blob_url": "https://api.github.com/scrubbed", + "changes": 2, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 1, + "filename": "reference-implementations/agt/packages/host-adapter/README.md", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "686f8c2b76842c5ae4204536941eb42e64fce93b", + "status": "modified" + }, + { + "additions": 69, + "blob_url": "https://api.github.com/scrubbed", + "changes": 75, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 6, + "filename": "reference-implementations/agt/packages/host-adapter/src/failure-kinds.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "d75aab56804d48bd98c15d1d50ccdf5de9127b37", + "status": "modified" + }, + { + "additions": 22, + "blob_url": "https://api.github.com/scrubbed", + "changes": 83, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 61, + "filename": "reference-implementations/agt/packages/host-adapter/src/failure-posture.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "7827105760bb7cb5291ae2211f5becbe898e0db4", + "status": "modified" + }, + { + "additions": 72, + "blob_url": "https://api.github.com/scrubbed", + "changes": 78, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 6, + "filename": "reference-implementations/agt/packages/host-adapter/src/govern-step.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "b3dbb87ce254da55cf0e81b68af2491aa2783c98", + "status": "modified" + }, + { + "additions": 54, + "blob_url": "https://api.github.com/scrubbed", + "changes": 59, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 5, + "filename": "reference-implementations/agt/packages/host-adapter/src/guardian-client.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "8844f3622a17636de2cd7f6473c00dbba336d490", + "status": "modified" + }, + { + "additions": 33, + "blob_url": "https://api.github.com/scrubbed", + "changes": 42, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 9, + "filename": "reference-implementations/agt/packages/host-adapter/src/handshake.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "d2a694e9c93806e7a73a6b30a9d924d1a3a22922", + "status": "modified" + }, + { + "additions": 10, + "blob_url": "https://api.github.com/scrubbed", + "changes": 10, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 0, + "filename": "reference-implementations/agt/packages/host-adapter/src/index.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "52bf82bf6cc7a89ee1ac6213c6e09fea5f24f171", + "status": "modified" + }, + { + "additions": 131, + "blob_url": "https://api.github.com/scrubbed", + "changes": 131, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 0, + "filename": "reference-implementations/agt/packages/host-adapter/src/sign-envelope.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "98865f9afdf678aab286c977911df9cf3539f5d7", + "status": "added" + }, + { + "additions": 294, + "blob_url": "https://api.github.com/scrubbed", + "changes": 297, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 3, + "filename": "reference-implementations/agt/packages/host-adapter/test/client.test.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "e5c9f3ac598710b94fdc7459d3f935b34f4b9045", + "status": "modified" + }, + { + "additions": 15, + "blob_url": "https://api.github.com/scrubbed", + "changes": 27, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 12, + "filename": "reference-implementations/agt/packages/host-adapter/test/failure-posture.test.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "696a054c226c42b976892c2f3fc793691b6fbc20", + "status": "modified" + }, + { + "additions": 172, + "blob_url": "https://api.github.com/scrubbed", + "changes": 173, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 1, + "filename": "reference-implementations/agt/packages/host-adapter/test/govern-step.test.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "b0ef1966f1cb484dc6eeb0e9812237549b8711f2", + "status": "modified" + }, + { + "additions": 4, + "blob_url": "https://api.github.com/scrubbed", + "changes": 6, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 2, + "filename": "reference-implementations/agt/packages/inspector/src/tail-session-context.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "f3ce5c7925262caf5bdf29c6d3bfdb43cb75766f", + "status": "modified" + }, + { + "additions": 5, + "blob_url": "https://api.github.com/scrubbed", + "changes": 5, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 0, + "filename": "reference-implementations/agt/packages/inspector/test/tail-session-context.test.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "37e3023fd29801231c998d7c38292654ef8ddab1", + "status": "modified" + }, + { + "additions": 2, + "blob_url": "https://api.github.com/scrubbed", + "changes": 4, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 2, + "filename": "reference-implementations/agt/samples/trace/acs-trace-sample.jsonl", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "4a3a5707f9a281467abf1cc83fc74d8a55947c04", + "status": "modified" + }, + { + "additions": 14, + "blob_url": "https://api.github.com/scrubbed", + "changes": 14, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 0, + "filename": "reference-implementations/agt/scripts/make-sample-trace.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "678a1a7fb5f1b1c93b5e5956fc47159c917dbeb0", + "status": "modified" + }, + { + "additions": 12, + "blob_url": "https://api.github.com/scrubbed", + "changes": 14, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 2, + "filename": "reference-implementations/agt/test/dispositions.test.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "76d2ed8db77e284ea0f3d349819eecf913b37587", + "status": "modified" + } +] diff --git a/tests/fixtures/pr_intake/files_page2.json b/tests/fixtures/pr_intake/files_page2.json new file mode 100644 index 00000000..23ab367d --- /dev/null +++ b/tests/fixtures/pr_intake/files_page2.json @@ -0,0 +1,74 @@ +[ + { + "additions": 6, + "blob_url": "https://api.github.com/scrubbed", + "changes": 10, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 4, + "filename": "reference-implementations/agt/test/envelope-log-sink-roundtrip.test.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "ce9063b93cd1b1ddf92d24b7dd75612e182a2271", + "status": "modified" + }, + { + "additions": 34, + "blob_url": "https://api.github.com/scrubbed", + "changes": 79, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 45, + "filename": "reference-implementations/agt/test/handshake-declares-what-it-evaluates.test.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "478c7384e176419c6adc8e985c02e42233f17536", + "status": "modified" + }, + { + "additions": 55, + "blob_url": "https://api.github.com/scrubbed", + "changes": 55, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 0, + "filename": "reference-implementations/agt/test/helpers/handshake.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "c0712a9d134dfacee8b6572d61d37323eaf2cfa4", + "status": "added" + }, + { + "additions": 6, + "blob_url": "https://api.github.com/scrubbed", + "changes": 7, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 1, + "filename": "reference-implementations/agt/test/readme-captures.test.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "c79b79ad683b78f65ea325e3a1166e84ea78cede", + "status": "modified" + }, + { + "additions": 3, + "blob_url": "https://api.github.com/scrubbed", + "changes": 3, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 0, + "filename": "reference-implementations/agt/test/sample-trace.test.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "2242bf07f05befff2fd91a806744f719a6397725", + "status": "modified" + }, + { + "additions": 2, + "blob_url": "https://api.github.com/scrubbed", + "changes": 2, + "contents_url": "https://api.github.com/scrubbed", + "deletions": 0, + "filename": "reference-implementations/agt/test/session-context-roundtrip.test.ts", + "patch": "scrubbed", + "raw_url": "https://api.github.com/scrubbed", + "sha": "049fba7d15245638f59caf51a402a568ab07e35d", + "status": "modified" + } +] diff --git a/tests/fixtures/pr_intake/issue_comments.json b/tests/fixtures/pr_intake/issue_comments.json new file mode 100644 index 00000000..f27dee1a --- /dev/null +++ b/tests/fixtures/pr_intake/issue_comments.json @@ -0,0 +1,942 @@ +[ + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-09-10T17:49:19Z", + "html_url": "https://api.github.com/scrubbed", + "id": 5623013780, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABTyhhlA", + "performed_via_github_app": null, + "pin": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-09-10T17:49:19Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-18", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-09-10T21:48:41Z", + "html_url": "https://api.github.com/scrubbed", + "id": 5625894392, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABT1RV-A", + "performed_via_github_app": null, + "pin": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-09-10T21:48:41Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-19", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-09-13T14:23:38Z", + "html_url": "https://api.github.com/scrubbed", + "id": 5653852910, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABUP7y7g", + "performed_via_github_app": null, + "pin": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-09-13T14:23:38Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-20", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-09-14T08:35:42Z", + "html_url": "https://api.github.com/scrubbed", + "id": 5661254098, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABUW_h0g", + "performed_via_github_app": null, + "pin": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-09-14T08:35:42Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-09-14T17:11:48Z", + "html_url": "https://api.github.com/scrubbed", + "id": 5667790667, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABUdOfSw", + "performed_via_github_app": null, + "pin": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-09-14T17:11:48Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-20", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-09-14T20:49:52Z", + "html_url": "https://api.github.com/scrubbed", + "id": 5670617873, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABUf7DEQ", + "performed_via_github_app": null, + "pin": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-09-14T20:49:52Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-21", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-09-16T14:57:23Z", + "html_url": "https://api.github.com/scrubbed", + "id": 5699582096, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABU7i4kA", + "performed_via_github_app": null, + "pin": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-09-16T14:57:23Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-21", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-09-17T13:45:22Z", + "html_url": "https://api.github.com/scrubbed", + "id": 5715401986, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABVKodAg", + "performed_via_github_app": null, + "pin": null, + "reactions": { + "+1": 1, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 1, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-09-17T13:45:22Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-19", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-09-30T05:41:03Z", + "html_url": "https://api.github.com/scrubbed", + "id": 5904876213, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABX_VCtQ", + "performed_via_github_app": null, + "pin": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-09-30T05:41:03Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-22", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-18T03:54:31Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6245390559, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABdEEY3w", + "performed_via_github_app": null, + "pin": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-18T03:54:31Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-18T04:41:04Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6245744992, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABdEaBYA", + "performed_via_github_app": null, + "pin": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-18T04:41:04Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-22", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-18T09:18:40Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6248154477, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABdGtFbQ", + "performed_via_github_app": null, + "pin": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-18T09:18:40Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-15", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-19T05:02:08Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6260052066, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABdSDQYg", + "performed_via_github_app": null, + "pin": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-19T05:02:08Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-24", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-27T13:37:35Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6443287884, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABgAzFTA", + "performed_via_github_app": null, + "pin": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-27T13:37:35Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-17", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-27T14:44:57Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6445019760, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABgCcycA", + "performed_via_github_app": null, + "pin": null, + "reactions": { + "+1": 1, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 1, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-27T14:44:57Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-24", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-27T14:55:32Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6445272404, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABgCsNVA", + "performed_via_github_app": null, + "pin": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-27T14:55:32Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-17", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-27T15:07:48Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6445574305, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABgC-ooQ", + "performed_via_github_app": null, + "pin": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-27T15:07:48Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-24", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-27T15:29:11Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6446113444, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABgDfipA", + "performed_via_github_app": null, + "pin": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-27T15:29:11Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-17", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-27T15:34:30Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6446241269, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABgDnV9Q", + "performed_via_github_app": null, + "pin": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-27T15:34:30Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-17", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-27T19:25:27Z", + "html_url": "https://api.github.com/scrubbed", + "id": 6451478180, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABgIm-pA", + "performed_via_github_app": null, + "pin": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-27T19:25:27Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-24", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + } +] diff --git a/tests/fixtures/pr_intake/population.json b/tests/fixtures/pr_intake/population.json new file mode 100644 index 00000000..5d7688e3 --- /dev/null +++ b/tests/fixtures/pr_intake/population.json @@ -0,0 +1,5909 @@ +{ + "generated_by": "tests/fixtures/pr_intake/record_population.py", + "recorded": "2026-10-29", + "rows": [ + { + "facts": { + "number": 1005, + "state": "open", + "draft": false, + "author": "lead-5", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000001", + "created_at": "2026-06-15T08:32:20+00:00", + "updated_at": "2026-10-29T14:22:42+00:00", + "labels": [ + "priority:P0" + ], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "docs/topics/f2.md", + "tools/f3.py", + "tools/f4.py", + "tools/f5.py", + "tools/f6.py", + "tools/f7.py", + "tools/f8.py", + "tools/f9.py", + "tools/f10.py", + "tools/f11.py", + "tools/f12.py", + "tools/f13.py", + "tools/f14.py", + "tools/f15.py", + "tools/f16.py", + "tools/f17.py", + "tools/f18.py", + "tools/f19.py", + "tools/f20.py", + "tools/f21.py", + "tools/f22.py", + "tools/f23.py", + "tools/f24.py", + "tools/f25.py", + "tools/f26.py", + "tools/f27.py", + "tools/f28.py", + "tools/f29.py", + "tools/f30.py", + "tools/f31.py", + "tools/f32.py", + "tools/f33.py", + "tools/f34.py", + "tools/f35.py", + "tools/f36.py", + "tools/f37.py", + "tools/f38.py", + "tools/f39.py", + "tools/f40.py", + "tools/f41.py", + "tools/f42.py", + "tools/f43.py", + "tools/f44.py", + "tools/f45.py", + "tools/f46.py", + "tools/f47.py", + "tools/f48.py", + "tools/f49.py", + "tools/f50.py", + "tools/f51.py", + "tools/f52.py", + "tools/f53.py", + "tools/f54.py", + "tools/f55.py", + "tools/f56.py", + "tools/f57.py", + "tools/f58.py", + "tools/f59.py", + "tools/f60.py", + "tools/f61.py", + "tools/f62.py", + "tools/f63.py", + "tools/f64.py", + "tools/f65.py", + "tools/f66.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1009, + "kind": "none", + "is_pull": false, + "state": "open", + "labels": [ + "priority:P1", + "scope:in-focus", + "status:accepted", + "type:proposal" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1004, + "kind": "none", + "is_pull": true, + "state": "closed", + "labels": [ + "priority:P0" + ], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1008, + "kind": "none", + "is_pull": false, + "state": "open", + "labels": [ + "priority:P1", + "scope:in-focus", + "status:accepted", + "type:bug" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1036, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "scope:in-focus", + "status:accepted", + "type:refimpl" + ], + "assignees": [ + "lead-5" + ], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [ + { + "login": "user-1", + "state": "COMMENTED", + "association": "NONE", + "submitted_at": "2026-06-21T08:55:00+00:00", + "commit_id": "0000000000000000000000000000000000000002" + }, + { + "login": "lead-5", + "state": "COMMENTED", + "association": "COLLABORATOR", + "submitted_at": "2026-06-21T09:12:57+00:00", + "commit_id": "0000000000000000000000000000000000000002" + }, + { + "login": "lead-5", + "state": "COMMENTED", + "association": "COLLABORATOR", + "submitted_at": "2026-06-21T09:14:55+00:00", + "commit_id": "0000000000000000000000000000000000000002" + }, + { + "login": "lead-1", + "state": "COMMENTED", + "association": "COLLABORATOR", + "submitted_at": "2026-08-21T12:11:33+00:00", + "commit_id": "0000000000000000000000000000000000000003" + }, + { + "login": "lead-1", + "state": "CHANGES_REQUESTED", + "association": "COLLABORATOR", + "submitted_at": "2026-10-05T20:52:32+00:00", + "commit_id": "0000000000000000000000000000000000000001" + } + ], + "last_unlabel": null, + "last_author_comment_at": "2026-09-24T11:13:53+00:00", + "bot_comment": null, + "auto_merge": false, + "author_record": false, + "held_use": { + "1008": [ + 1005 + ], + "1009": [ + 1005 + ], + "1036": [ + 1005 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "pass", + "route": "roster", + "reasons": [] + } + }, + { + "facts": { + "number": 1006, + "state": "open", + "draft": false, + "author": "user-2", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000004", + "created_at": "2026-07-19T23:53:36+00:00", + "updated_at": "2026-10-05T09:06:27+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1002, + "kind": "none", + "is_pull": true, + "state": "closed", + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1001, + "kind": "none", + "is_pull": false, + "state": null, + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": true + }, + { + "number": 1004, + "kind": "none", + "is_pull": true, + "state": "closed", + "labels": [ + "priority:P0" + ], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1037, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "scope:in-focus", + "status:accepted", + "type:proposal" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [ + { + "login": "lead-12", + "state": "CHANGES_REQUESTED", + "association": "MEMBER", + "submitted_at": "2026-09-06T18:04:06+00:00", + "commit_id": "0000000000000000000000000000000000000005" + } + ], + "last_unlabel": null, + "last_author_comment_at": "2026-09-18T00:26:13+00:00", + "bot_comment": null, + "auto_merge": false, + "author_record": false, + "held_use": { + "1037": [ + 1006 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "awaiting_rereview", + "not_held" + ] + } + }, + { + "facts": { + "number": 1018, + "state": "open", + "draft": true, + "author": "user-3", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000006", + "created_at": "2026-09-10T05:54:58+00:00", + "updated_at": "2026-10-18T11:43:14+00:00", + "labels": [ + "type:refimpl" + ], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py", + "tools/f3.py", + "tools/f4.py", + "tools/f5.py", + "tools/f6.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1005, + "kind": "none", + "is_pull": true, + "state": "open", + "labels": [ + "priority:P0" + ], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1007, + "kind": "none", + "is_pull": false, + "state": "open", + "labels": [ + "priority:P2", + "scope:in-focus", + "status:accepted", + "type:bug" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1012, + "kind": "none", + "is_pull": true, + "state": "closed", + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1013, + "kind": "none", + "is_pull": false, + "state": "open", + "labels": [ + "priority:P1", + "scope:in-focus", + "status:accepted", + "type:bug", + "workstream:sdk" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": null, + "auto_merge": false, + "author_record": false, + "held_use": { + "1007": [ + 1018 + ], + "1013": [ + 1018 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "skip", + "route": null, + "reasons": [] + } + }, + { + "facts": { + "number": 1019, + "state": "open", + "draft": false, + "author": "dependabot[bot]", + "author_is_bot": true, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000007", + "created_at": "2026-09-10T12:54:39+00:00", + "updated_at": "2026-10-13T21:33:00+00:00", + "labels": [ + "dependencies", + "status:needs-triage" + ], + "paths": [ + "tools/f0.py" + ], + "paths_truncated": false, + "references": [], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": "2026-09-10T12:54:40+00:00", + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": {}, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "skip", + "route": null, + "reasons": [] + } + }, + { + "facts": { + "number": 1020, + "state": "open", + "draft": false, + "author": "dependabot[bot]", + "author_is_bot": true, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000008", + "created_at": "2026-09-10T12:54:44+00:00", + "updated_at": "2026-10-13T21:32:59+00:00", + "labels": [ + "dependencies", + "status:needs-triage" + ], + "paths": [ + "tools/f0.py" + ], + "paths_truncated": false, + "references": [], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": "2026-09-10T12:54:45+00:00", + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": {}, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "skip", + "route": null, + "reasons": [] + } + }, + { + "facts": { + "number": 1029, + "state": "open", + "draft": false, + "author": "user-4", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000009", + "created_at": "2026-09-13T05:22:50+00:00", + "updated_at": "2026-10-20T01:06:57+00:00", + "labels": [], + "paths": [ + "docs/topics/f0.md", + "tools/f1.py", + "tools/f2.py", + "tools/f3.py", + "tools/f4.py", + "tools/f5.py", + "tools/f6.py", + "tools/f7.py", + "tools/f8.py", + "tools/f9.py", + "tools/f10.py", + "tools/f11.py", + "tools/f12.py", + "tools/f13.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1067, + "kind": "none", + "is_pull": true, + "state": "closed", + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1025, + "kind": "contribute", + "is_pull": false, + "state": "closed", + "labels": [ + "help wanted", + "priority:P1", + "scope:in-focus", + "status:accepted", + "workstream:coding-agents" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [ + { + "login": "user-5", + "state": "COMMENTED", + "association": "NONE", + "submitted_at": "2026-10-17T02:25:10+00:00", + "commit_id": "000000000000000000000000000000000000000a" + }, + { + "login": "user-5", + "state": "APPROVED", + "association": "NONE", + "submitted_at": "2026-10-20T01:06:57+00:00", + "commit_id": "0000000000000000000000000000000000000009" + } + ], + "last_unlabel": null, + "last_author_comment_at": "2026-10-15T22:16:07+00:00", + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": {}, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "closed_accepted" + ] + } + }, + { + "facts": { + "number": 1030, + "state": "open", + "draft": false, + "author": "user-6", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "000000000000000000000000000000000000000b", + "created_at": "2026-09-13T11:53:36+00:00", + "updated_at": "2026-10-05T09:05:31+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py", + "tools/f3.py", + "tools/f4.py", + "tools/f5.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1026, + "kind": "contribute", + "is_pull": false, + "state": "open", + "labels": [ + "help wanted", + "priority:P1", + "scope:in-focus", + "status:accepted", + "workstream:coding-agents" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": "2026-09-13T14:23:38+00:00", + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": null, + "auto_merge": false, + "author_record": false, + "held_use": { + "1026": [ + 1030 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "not_held" + ] + } + }, + { + "facts": { + "number": 1033, + "state": "open", + "draft": false, + "author": "lead-12", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "000000000000000000000000000000000000000c", + "created_at": "2026-09-15T13:49:04+00:00", + "updated_at": "2026-10-05T09:05:23+00:00", + "labels": [], + "paths": [ + "tools/f0.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1034, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "scope:in-focus", + "status:accepted", + "type:refimpl" + ], + "assignees": [ + "lead-12" + ], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": [ + "lead-12", + "2026-09-15T13:51:31+00:00" + ], + "last_author_comment_at": null, + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": { + "1034": [ + 1033 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "pass", + "route": "roster", + "reasons": [] + } + }, + { + "facts": { + "number": 1043, + "state": "open", + "draft": false, + "author": "lead-9", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "000000000000000000000000000000000000000d", + "created_at": "2026-09-19T03:33:17+00:00", + "updated_at": "2026-10-05T09:05:14+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "docs/topics/f2.md", + "tools/f3.py", + "tools/f4.py", + "tools/f5.py", + "tools/f6.py", + "tools/f7.py", + "tools/f8.py", + "tools/f9.py", + "tools/f10.py", + "tools/f11.py", + "tools/f12.py", + "tools/f13.py", + "tools/f14.py", + "tools/f15.py", + "tools/f16.py", + "tools/f17.py", + "tools/f18.py", + "tools/f19.py", + "tools/f20.py", + "tools/f21.py", + "tools/f22.py", + "tools/f23.py", + "tools/f24.py", + "tools/f25.py", + "tools/f26.py", + "tools/f27.py", + "tools/f28.py", + "tools/f29.py", + "tools/f30.py", + "tools/f31.py", + "tools/f32.py", + "tools/f33.py", + "tools/f34.py", + "tools/f35.py", + "tools/f36.py", + "tools/f37.py", + "tools/f38.py", + "tools/f39.py", + "tools/f40.py", + "tools/f41.py", + "tools/f42.py", + "tools/f43.py", + "tools/f44.py", + "tools/f45.py", + "tools/f46.py", + "tools/f47.py", + "tools/f48.py", + "tools/f49.py", + "tools/f50.py", + "tools/f51.py", + "tools/f52.py", + "tools/f53.py", + "tools/f54.py", + "tools/f55.py", + "tools/f56.py", + "tools/f57.py", + "tools/f58.py", + "tools/f59.py", + "tools/f60.py", + "tools/f61.py", + "tools/f62.py", + "tools/f63.py", + "tools/f64.py", + "tools/f65.py", + "tools/f66.py", + "tools/f67.py", + "tools/f68.py", + "tools/f69.py", + "tools/f70.py", + "tools/f71.py", + "tools/f72.py", + "tools/f73.py", + "tools/f74.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1005, + "kind": "none", + "is_pull": true, + "state": "open", + "labels": [ + "priority:P0" + ], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1048, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "priority:P1", + "scope:in-focus", + "status:accepted", + "type:refimpl", + "workstream:coding-agents" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1024, + "kind": "contribute", + "is_pull": false, + "state": "open", + "labels": [ + "help wanted", + "priority:P1", + "scope:in-focus", + "status:accepted", + "workstream:coding-agents" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": "2026-09-16T15:20:34+00:00", + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": "2026-09-24T13:19:59+00:00", + "bot_comment": null, + "auto_merge": false, + "author_record": false, + "held_use": { + "1024": [ + 1043 + ], + "1048": [ + 1043 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "pass", + "route": "roster", + "reasons": [] + } + }, + { + "facts": { + "number": 1047, + "state": "open", + "draft": false, + "author": "user-5", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "000000000000000000000000000000000000000e", + "created_at": "2026-09-21T07:03:41+00:00", + "updated_at": "2026-10-25T13:04:24+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py", + "tools/f3.py", + "tools/f4.py", + "tools/f5.py", + "tools/f6.py", + "tools/f7.py", + "tools/f8.py", + "tools/f9.py", + "tools/f10.py", + "tools/f11.py", + "tools/f12.py", + "tools/f13.py", + "tools/f14.py", + "tools/f15.py", + "tools/f16.py", + "tools/f17.py", + "tools/f18.py", + "tools/f19.py", + "tools/f20.py", + "tools/f21.py", + "tools/f22.py", + "tools/f23.py", + "tools/f24.py", + "tools/f25.py", + "tools/f26.py", + "tools/f27.py", + "tools/f28.py", + "tools/f29.py", + "tools/f30.py", + "tools/f31.py", + "tools/f32.py", + "tools/f33.py", + "tools/f34.py", + "tools/f35.py", + "tools/f36.py", + "tools/f37.py", + "tools/f38.py", + "tools/f39.py", + "tools/f40.py", + "tools/f41.py", + "tools/f42.py", + "tools/f43.py", + "tools/f44.py", + "tools/f45.py", + "tools/f46.py", + "tools/f47.py", + "tools/f48.py", + "tools/f49.py", + "tools/f50.py", + "tools/f51.py", + "tools/f52.py", + "tools/f53.py", + "tools/f54.py", + "tools/f55.py", + "tools/f56.py", + "tools/f57.py", + "tools/f58.py", + "tools/f59.py", + "tools/f60.py", + "tools/f61.py", + "tools/f62.py", + "tools/f63.py", + "tools/f64.py", + "tools/f65.py", + "tools/f66.py", + "tools/f67.py", + "tools/f68.py", + "tools/f69.py", + "tools/f70.py", + "tools/f71.py", + "tools/f72.py", + "tools/f73.py", + "tools/f74.py", + "tools/f75.py", + "tools/f76.py", + "tools/f77.py", + "tools/f78.py", + "tools/f79.py", + "tools/f80.py", + "tools/f81.py", + "tools/f82.py", + "tools/f83.py", + "tools/f84.py", + "tools/f85.py", + "tools/f86.py", + "tools/f87.py", + "tools/f88.py", + "tools/f89.py", + "tools/f90.py", + "tools/f91.py", + "tools/f92.py", + "tools/f93.py", + "tools/f94.py", + "tools/f95.py", + "tools/f96.py", + "tools/f97.py", + "tools/f98.py", + "tools/f99.py", + "tools/f100.py", + "tools/f101.py", + "tools/f102.py", + "tools/f103.py", + "tools/f104.py", + "tools/f105.py", + "tools/f106.py", + "tools/f107.py", + "tools/f108.py", + "tools/f109.py", + "tools/f110.py", + "tools/f111.py", + "tools/f112.py", + "tools/f113.py", + "tools/f114.py", + "tools/f115.py", + "tools/f116.py", + "tools/f117.py", + "tools/f118.py", + "tools/f119.py", + "tools/f120.py", + "tools/f121.py", + "tools/f122.py", + "tools/f123.py", + "tools/f124.py", + "tools/f125.py", + "tools/f126.py", + "tools/f127.py", + "tools/f128.py", + "tools/f129.py", + "tools/f130.py", + "tools/f131.py", + "tools/f132.py", + "tools/f133.py", + "tools/f134.py", + "tools/f135.py", + "tools/f136.py", + "tools/f137.py", + "tools/f138.py", + "tools/f139.py", + "tools/f140.py", + "tools/f141.py", + "tools/f142.py", + "tools/f143.py", + "tools/f144.py", + "tools/f145.py", + "tools/f146.py", + "tools/f147.py", + "tools/f148.py", + "tools/f149.py", + "tools/f150.py", + "tools/f151.py", + "tools/f152.py", + "tools/f153.py", + "tools/f154.py", + "tools/f155.py", + "tools/f156.py", + "tools/f157.py", + "tools/f158.py", + "tools/f159.py", + "tools/f160.py", + "tools/f161.py", + "tools/f162.py", + "tools/f163.py", + "tools/f164.py", + "tools/f165.py", + "tools/f166.py", + "tools/f167.py", + "tools/f168.py", + "tools/f169.py", + "tools/f170.py", + "tools/f171.py", + "tools/f172.py", + "tools/f173.py", + "tools/f174.py", + "tools/f175.py", + "tools/f176.py", + "tools/f177.py", + "tools/f178.py", + "tools/f179.py", + "tools/f180.py", + "tools/f181.py", + "tools/f182.py", + "tools/f183.py", + "tools/f184.py", + "tools/f185.py", + "tools/f186.py", + "tools/f187.py", + "tools/f188.py", + "tools/f189.py", + "tools/f190.py", + "tools/f191.py", + "tools/f192.py", + "tools/f193.py", + "tools/f194.py", + "tools/f195.py", + "tools/f196.py", + "tools/f197.py", + "tools/f198.py", + "tools/f199.py", + "tools/f200.py", + "tools/f201.py", + "tools/f202.py", + "tools/f203.py", + "tools/f204.py", + "tools/f205.py", + "tools/f206.py", + "tools/f207.py", + "tools/f208.py", + "tools/f209.py", + "tools/f210.py", + "tools/f211.py", + "tools/f212.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1022, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "help wanted", + "priority:P1", + "scope:in-focus", + "status:accepted", + "workstream:coding-agents" + ], + "assignees": [ + "user-5" + ], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1062, + "kind": "none", + "is_pull": true, + "state": "open", + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1074, + "kind": "none", + "is_pull": true, + "state": "closed", + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [ + { + "login": "lead-12", + "state": "CHANGES_REQUESTED", + "association": "MEMBER", + "submitted_at": "2026-10-12T22:26:43+00:00", + "commit_id": "000000000000000000000000000000000000000f" + }, + { + "login": "lead-3", + "state": "APPROVED", + "association": "COLLABORATOR", + "submitted_at": "2026-10-25T13:04:24+00:00", + "commit_id": "000000000000000000000000000000000000000e" + } + ], + "last_unlabel": null, + "last_author_comment_at": "2026-10-25T02:25:00+00:00", + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": { + "1022": [ + 1047 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "pass", + "route": "review", + "reasons": [] + } + }, + { + "facts": { + "number": 1050, + "state": "open", + "draft": true, + "author": "user-7", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000010", + "created_at": "2026-09-22T16:18:22+00:00", + "updated_at": "2026-10-14T16:44:23+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py", + "tools/f3.py", + "tools/f4.py", + "tools/f5.py", + "tools/f6.py", + "tools/f7.py", + "tools/f8.py", + "tools/f9.py", + "tools/f10.py", + "tools/f11.py", + "tools/f12.py", + "tools/f13.py", + "tools/f14.py", + "tools/f15.py", + "tools/f16.py", + "tools/f17.py", + "tools/f18.py", + "tools/f19.py", + "tools/f20.py", + "tools/f21.py", + "tools/f22.py", + "tools/f23.py", + "tools/f24.py", + "tools/f25.py", + "tools/f26.py", + "tools/f27.py", + "tools/f28.py", + "tools/f29.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1021, + "kind": "contribute", + "is_pull": false, + "state": "open", + "labels": [ + "help wanted", + "priority:P1", + "scope:in-focus", + "status:accepted", + "workstream:coding-agents" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1018, + "kind": "none", + "is_pull": true, + "state": "open", + "labels": [ + "type:refimpl" + ], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1047, + "kind": "none", + "is_pull": true, + "state": "open", + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [ + { + "login": "lead-12", + "state": "CHANGES_REQUESTED", + "association": "MEMBER", + "submitted_at": "2026-10-12T23:04:47+00:00", + "commit_id": "0000000000000000000000000000000000000011" + } + ], + "last_unlabel": null, + "last_author_comment_at": "2026-10-14T16:44:23+00:00", + "bot_comment": null, + "auto_merge": false, + "author_record": false, + "held_use": { + "1021": [ + 1050 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "skip", + "route": null, + "reasons": [] + } + }, + { + "facts": { + "number": 1052, + "state": "open", + "draft": false, + "author": "user-8", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000012", + "created_at": "2026-09-25T13:58:09+00:00", + "updated_at": "2026-10-29T14:38:42+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py", + "tools/f3.py", + "tools/f4.py", + "tools/f5.py", + "tools/f6.py", + "tools/f7.py", + "tools/f8.py", + "tools/f9.py", + "tools/f10.py", + "tools/f11.py", + "tools/f12.py", + "tools/f13.py", + "tools/f14.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1023, + "kind": "contribute", + "is_pull": false, + "state": "open", + "labels": [ + "help wanted", + "priority:P1", + "scope:in-focus", + "status:accepted", + "workstream:coding-agents" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": "2026-09-25T08:35:11+00:00", + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [ + { + "login": "lead-12", + "state": "CHANGES_REQUESTED", + "association": "MEMBER", + "submitted_at": "2026-10-12T22:46:19+00:00", + "commit_id": "0000000000000000000000000000000000000013" + }, + { + "login": "lead-12", + "state": "APPROVED", + "association": "MEMBER", + "submitted_at": "2026-10-29T14:19:38+00:00", + "commit_id": "0000000000000000000000000000000000000012" + } + ], + "last_unlabel": null, + "last_author_comment_at": "2026-10-29T14:38:42+00:00", + "bot_comment": null, + "auto_merge": false, + "author_record": false, + "held_use": { + "1023": [ + 1052 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "pass", + "route": "review", + "reasons": [] + } + }, + { + "facts": { + "number": 1061, + "state": "open", + "draft": false, + "author": "user-9", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000014", + "created_at": "2026-10-05T11:44:17+00:00", + "updated_at": "2026-10-11T00:01:14+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "docs/topics/f1.md", + "tools/f2.py", + "tools/f3.py", + "tools/f4.py", + "tools/f5.py", + "tools/f6.py", + "tools/f7.py", + "tools/f8.py", + "tools/f9.py", + "tools/f10.py", + "tools/f11.py", + "tools/f12.py", + "tools/f13.py", + "tools/f14.py", + "tools/f15.py", + "tools/f16.py", + "tools/f17.py", + "tools/f18.py", + "tools/f19.py", + "tools/f20.py", + "tools/f21.py", + "tools/f22.py", + "tools/f23.py", + "tools/f24.py", + "tools/f25.py", + "tools/f26.py", + "tools/f27.py", + "tools/f28.py", + "tools/f29.py", + "tools/f30.py", + "tools/f31.py", + "tools/f32.py", + "tools/f33.py", + "tools/f34.py", + "tools/f35.py", + "tools/f36.py", + "tools/f37.py", + "tools/f38.py", + "tools/f39.py", + "tools/f40.py", + "tools/f41.py", + "tools/f42.py", + "tools/f43.py", + "tools/f44.py", + "tools/f45.py", + "tools/f46.py", + "tools/f47.py", + "tools/f48.py", + "tools/f49.py", + "tools/f50.py", + "tools/f51.py", + "tools/f52.py", + "tools/f53.py", + "tools/f54.py", + "tools/f55.py", + "tools/f56.py", + "tools/f57.py", + "tools/f58.py", + "tools/f59.py", + "tools/f60.py", + "tools/f61.py", + "tools/f62.py", + "tools/f63.py", + "tools/f64.py", + "tools/f65.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1054, + "kind": "none", + "is_pull": false, + "state": null, + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": true + }, + { + "number": 1009, + "kind": "contribute", + "is_pull": false, + "state": "open", + "labels": [ + "priority:P1", + "scope:in-focus", + "status:accepted", + "type:proposal" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": "2026-08-21T19:39:16+00:00", + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": "2026-10-06T16:38:02+00:00", + "bot_comment": null, + "auto_merge": false, + "author_record": false, + "held_use": { + "1009": [ + 1061, + 1066, + 1086, + 1087, + 1088, + 1091 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "not_held" + ] + } + }, + { + "facts": { + "number": 1062, + "state": "open", + "draft": false, + "author": "user-2", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000015", + "created_at": "2026-10-06T06:09:01+00:00", + "updated_at": "2026-10-29T14:21:37+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py", + "tools/f3.py", + "tools/f4.py", + "tools/f5.py", + "tools/f6.py", + "tools/f7.py", + "tools/f8.py", + "tools/f9.py", + "tools/f10.py", + "tools/f11.py", + "tools/f12.py", + "tools/f13.py", + "tools/f14.py", + "tools/f15.py", + "tools/f16.py", + "tools/f17.py", + "tools/f18.py", + "tools/f19.py", + "tools/f20.py", + "tools/f21.py", + "tools/f22.py", + "tools/f23.py", + "tools/f24.py", + "tools/f25.py", + "tools/f26.py", + "tools/f27.py", + "tools/f28.py", + "tools/f29.py", + "tools/f30.py", + "tools/f31.py", + "tools/f32.py", + "tools/f33.py", + "tools/f34.py", + "tools/f35.py", + "tools/f36.py", + "tools/f37.py", + "tools/f38.py", + "tools/f39.py", + "tools/f40.py", + "tools/f41.py", + "tools/f42.py", + "tools/f43.py", + "tools/f44.py", + "tools/f45.py", + "tools/f46.py", + "tools/f47.py", + "tools/f48.py", + "tools/f49.py", + "tools/f50.py", + "tools/f51.py", + "tools/f52.py", + "tools/f53.py", + "tools/f54.py", + "tools/f55.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1051, + "kind": "contribute", + "is_pull": false, + "state": "open", + "labels": [ + "scope:in-focus", + "status:accepted", + "type:conformance" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": "2026-10-06T09:14:28+00:00", + "unresolved": false + }, + { + "number": 1063, + "kind": "none", + "is_pull": false, + "state": null, + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": true + } + ], + "references_dropped": 0, + "reviews": [ + { + "login": "lead-1", + "state": "CHANGES_REQUESTED", + "association": "COLLABORATOR", + "submitted_at": "2026-10-06T19:30:33+00:00", + "commit_id": "0000000000000000000000000000000000000016" + }, + { + "login": "user-2", + "state": "COMMENTED", + "association": "NONE", + "submitted_at": "2026-10-07T08:22:47+00:00", + "commit_id": "0000000000000000000000000000000000000017" + }, + { + "login": "user-2", + "state": "COMMENTED", + "association": "NONE", + "submitted_at": "2026-10-07T08:22:49+00:00", + "commit_id": "0000000000000000000000000000000000000017" + }, + { + "login": "user-2", + "state": "COMMENTED", + "association": "NONE", + "submitted_at": "2026-10-07T08:22:50+00:00", + "commit_id": "0000000000000000000000000000000000000017" + }, + { + "login": "user-2", + "state": "COMMENTED", + "association": "NONE", + "submitted_at": "2026-10-07T08:22:52+00:00", + "commit_id": "0000000000000000000000000000000000000017" + }, + { + "login": "user-2", + "state": "COMMENTED", + "association": "NONE", + "submitted_at": "2026-10-07T08:22:53+00:00", + "commit_id": "0000000000000000000000000000000000000017" + }, + { + "login": "user-2", + "state": "COMMENTED", + "association": "NONE", + "submitted_at": "2026-10-07T08:22:55+00:00", + "commit_id": "0000000000000000000000000000000000000017" + }, + { + "login": "user-2", + "state": "COMMENTED", + "association": "NONE", + "submitted_at": "2026-10-07T08:22:56+00:00", + "commit_id": "0000000000000000000000000000000000000017" + }, + { + "login": "user-2", + "state": "COMMENTED", + "association": "NONE", + "submitted_at": "2026-10-08T11:23:04+00:00", + "commit_id": "0000000000000000000000000000000000000018" + }, + { + "login": "lead-12", + "state": "CHANGES_REQUESTED", + "association": "MEMBER", + "submitted_at": "2026-10-13T14:24:13+00:00", + "commit_id": "0000000000000000000000000000000000000019" + }, + { + "login": "lead-12", + "state": "CHANGES_REQUESTED", + "association": "MEMBER", + "submitted_at": "2026-10-29T14:21:37+00:00", + "commit_id": "0000000000000000000000000000000000000015" + } + ], + "last_unlabel": null, + "last_author_comment_at": "2026-10-28T19:30:07+00:00", + "bot_comment": null, + "auto_merge": false, + "author_record": false, + "held_use": { + "1051": [ + 1062 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "pass", + "route": "review", + "reasons": [] + } + }, + { + "facts": { + "number": 1066, + "state": "open", + "draft": false, + "author": "user-9", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "000000000000000000000000000000000000001a", + "created_at": "2026-10-09T14:44:16+00:00", + "updated_at": "2026-10-23T23:35:47+00:00", + "labels": [], + "paths": [ + "tools/f0.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1009, + "kind": "contribute", + "is_pull": false, + "state": "open", + "labels": [ + "priority:P1", + "scope:in-focus", + "status:accepted", + "type:proposal" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": "2026-08-21T19:39:16+00:00", + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [ + { + "login": "lead-9", + "state": "APPROVED", + "association": "COLLABORATOR", + "submitted_at": "2026-10-22T15:46:28+00:00", + "commit_id": "000000000000000000000000000000000000001a" + } + ], + "last_unlabel": null, + "last_author_comment_at": "2026-10-23T23:35:47+00:00", + "bot_comment": null, + "auto_merge": false, + "author_record": false, + "held_use": { + "1009": [ + 1061, + 1066, + 1086, + 1087, + 1088, + 1091 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "pass", + "route": "review", + "reasons": [] + } + }, + { + "facts": { + "number": 1068, + "state": "open", + "draft": false, + "author": "user-10", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "000000000000000000000000000000000000001b", + "created_at": "2026-10-10T02:36:33+00:00", + "updated_at": "2026-10-28T02:59:22+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py", + "tools/f3.py", + "tools/f4.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1046, + "kind": "contribute", + "is_pull": false, + "state": "open", + "labels": [ + "scope:in-focus", + "status:accepted", + "type:bug" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1062, + "kind": "none", + "is_pull": true, + "state": "open", + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [ + { + "login": "user-5", + "state": "COMMENTED", + "association": "NONE", + "submitted_at": "2026-10-27T23:06:18+00:00", + "commit_id": "000000000000000000000000000000000000001c" + } + ], + "last_unlabel": null, + "last_author_comment_at": "2026-10-27T01:37:01+00:00", + "bot_comment": null, + "auto_merge": false, + "author_record": false, + "held_use": { + "1046": [ + 1068 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "not_held" + ] + } + }, + { + "facts": { + "number": 1069, + "state": "open", + "draft": false, + "author": "lead-9", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "000000000000000000000000000000000000001d", + "created_at": "2026-10-10T04:45:48+00:00", + "updated_at": "2026-10-10T04:49:55+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1064, + "kind": "contribute", + "is_pull": false, + "state": "open", + "labels": [ + "scope:in-focus", + "status:accepted", + "type:docs", + "workstream:docs" + ], + "assignees": [ + "lead-9" + ], + "milestoned": false, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1032, + "kind": "none", + "is_pull": false, + "state": "closed", + "labels": [ + "scope:in-focus", + "status:accepted", + "type:docs" + ], + "assignees": [ + "lead-12" + ], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1065, + "kind": "none", + "is_pull": false, + "state": null, + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": true + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": null, + "auto_merge": false, + "author_record": false, + "held_use": { + "1064": [ + 1069 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "pass", + "route": "roster", + "reasons": [] + } + }, + { + "facts": { + "number": 1070, + "state": "open", + "draft": false, + "author": "user-11", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "main", + "head_sha": "000000000000000000000000000000000000001e", + "created_at": "2026-10-10T10:21:18+00:00", + "updated_at": "2026-10-10T10:21:31+00:00", + "labels": [ + "status:needs-triage" + ], + "paths": [ + "docs/topics/f0.md" + ], + "paths_truncated": false, + "references": [], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": {}, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "no_accepted" + ] + } + }, + { + "facts": { + "number": 1071, + "state": "open", + "draft": true, + "author": "user-12", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "000000000000000000000000000000000000001f", + "created_at": "2026-10-10T14:41:12+00:00", + "updated_at": "2026-10-28T04:20:00+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py", + "tools/f3.py", + "tools/f4.py", + "tools/f5.py", + "tools/f6.py", + "tools/f7.py", + "tools/f8.py", + "tools/f9.py", + "tools/f10.py", + "tools/f11.py", + "tools/f12.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1062, + "kind": "none", + "is_pull": true, + "state": "open", + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1049, + "kind": "contribute", + "is_pull": false, + "state": "open", + "labels": [ + "scope:in-focus", + "status:accepted", + "type:conformance" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": "2026-09-30T04:56:33+00:00", + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": "2026-10-23T04:29:22+00:00", + "bot_comment": null, + "auto_merge": false, + "author_record": false, + "held_use": { + "1049": [ + 1071 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "skip", + "route": null, + "reasons": [] + } + }, + { + "facts": { + "number": 1072, + "state": "open", + "draft": false, + "author": "user-13", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000020", + "created_at": "2026-10-11T20:17:05+00:00", + "updated_at": "2026-10-12T18:46:34+00:00", + "labels": [], + "paths": [ + "tools/f0.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1057, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "scope:in-focus", + "status:accepted" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [ + { + "login": "lead-9", + "state": "APPROVED", + "association": "COLLABORATOR", + "submitted_at": "2026-10-12T18:46:34+00:00", + "commit_id": "0000000000000000000000000000000000000020" + } + ], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": true, + "held_use": { + "1057": [ + 1072 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "pass", + "route": "review", + "reasons": [] + } + }, + { + "facts": { + "number": 1073, + "state": "open", + "draft": false, + "author": "user-13", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000021", + "created_at": "2026-10-11T20:25:34+00:00", + "updated_at": "2026-10-11T20:25:47+00:00", + "labels": [], + "paths": [ + "tools/f0.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1056, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "scope:in-focus", + "status:accepted" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": true, + "held_use": { + "1056": [ + 1073 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "pass", + "route": "record", + "reasons": [] + } + }, + { + "facts": { + "number": 1077, + "state": "open", + "draft": false, + "author": "user-13", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000022", + "created_at": "2026-10-12T06:45:49+00:00", + "updated_at": "2026-10-12T06:46:00+00:00", + "labels": [], + "paths": [ + "tools/f0.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1017, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "priority:P2", + "scope:in-focus", + "status:accepted", + "type:proposal", + "workstream:sdk" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": true, + "held_use": { + "1017": [ + 1077 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "pass", + "route": "record", + "reasons": [] + } + }, + { + "facts": { + "number": 1081, + "state": "open", + "draft": false, + "author": "lead-9", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000023", + "created_at": "2026-10-13T05:17:29+00:00", + "updated_at": "2026-10-29T14:10:03+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1080, + "kind": "contribute", + "is_pull": false, + "state": "open", + "labels": [ + "scope:in-focus", + "status:accepted", + "type:docs", + "workstream:docs" + ], + "assignees": [ + "lead-9" + ], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1082, + "kind": "none", + "is_pull": true, + "state": "closed", + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1069, + "kind": "none", + "is_pull": true, + "state": "open", + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": null, + "auto_merge": false, + "author_record": false, + "held_use": { + "1080": [ + 1081 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "pass", + "route": "roster", + "reasons": [] + } + }, + { + "facts": { + "number": 1083, + "state": "open", + "draft": false, + "author": "user-14", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000024", + "created_at": "2026-10-15T18:00:20+00:00", + "updated_at": "2026-10-20T04:27:53+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py", + "tools/f3.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1076, + "kind": "none", + "is_pull": true, + "state": "closed", + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1058, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "scope:in-focus", + "status:accepted" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1065, + "kind": "none", + "is_pull": false, + "state": null, + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": true + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": "2026-10-20T04:27:52+00:00", + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": { + "1058": [ + 1083 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "not_held" + ] + } + }, + { + "facts": { + "number": 1085, + "state": "open", + "draft": false, + "author": "user-15", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000025", + "created_at": "2026-10-17T12:38:58+00:00", + "updated_at": "2026-10-17T12:39:14+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py", + "tools/f3.py", + "tools/f4.py", + "tools/f5.py", + "tools/f6.py", + "tools/f7.py", + "tools/f8.py", + "tools/f9.py", + "tools/f10.py", + "tools/f11.py", + "tools/f12.py", + "tools/f13.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1027, + "kind": "contribute", + "is_pull": false, + "state": "open", + "labels": [ + "scope:in-focus", + "status:accepted" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": "2026-09-10T17:49:34+00:00", + "unresolved": false + }, + { + "number": 1028, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "scope:in-focus", + "status:accepted" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": "2026-10-17T12:09:13+00:00", + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": { + "1027": [ + 1085 + ], + "1028": [ + 1085 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "not_held" + ] + } + }, + { + "facts": { + "number": 1086, + "state": "open", + "draft": false, + "author": "user-9", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000026", + "created_at": "2026-10-17T13:33:19+00:00", + "updated_at": "2026-10-17T13:33:21+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "docs/topics/f1.md", + "tools/f2.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1009, + "kind": "contribute", + "is_pull": false, + "state": "open", + "labels": [ + "priority:P1", + "scope:in-focus", + "status:accepted", + "type:proposal" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": "2026-08-21T19:39:16+00:00", + "unresolved": false + }, + { + "number": 1011, + "kind": "contribute", + "is_pull": false, + "state": "open", + "labels": [ + "scope:deferred", + "type:proposal" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1061, + "kind": "none", + "is_pull": true, + "state": "open", + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1060, + "kind": "none", + "is_pull": true, + "state": "closed", + "labels": [ + "status:needs-triage" + ], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1003, + "kind": "none", + "is_pull": false, + "state": "closed", + "labels": [ + "priority:P1", + "scope:in-focus", + "status:accepted", + "type:bug" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": null, + "auto_merge": false, + "author_record": false, + "held_use": { + "1009": [ + 1061, + 1066, + 1086, + 1087, + 1088, + 1091 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "closed_accepted", + "not_held" + ] + } + }, + { + "facts": { + "number": 1087, + "state": "open", + "draft": false, + "author": "user-9", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000027", + "created_at": "2026-10-17T15:06:04+00:00", + "updated_at": "2026-10-24T07:38:21+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1011, + "kind": "contribute", + "is_pull": false, + "state": "open", + "labels": [ + "scope:deferred", + "type:proposal" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1003, + "kind": "none", + "is_pull": false, + "state": "closed", + "labels": [ + "priority:P1", + "scope:in-focus", + "status:accepted", + "type:bug" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1051, + "kind": "none", + "is_pull": false, + "state": "open", + "labels": [ + "scope:in-focus", + "status:accepted", + "type:conformance" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": "2026-10-24T03:31:40+00:00", + "unresolved": false + }, + { + "number": 1009, + "kind": "contribute", + "is_pull": false, + "state": "open", + "labels": [ + "priority:P1", + "scope:in-focus", + "status:accepted", + "type:proposal" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": "2026-08-21T19:39:16+00:00", + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": null, + "auto_merge": false, + "author_record": false, + "held_use": { + "1009": [ + 1061, + 1066, + 1086, + 1087, + 1088, + 1091 + ], + "1051": [ + 1087 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "closed_accepted", + "not_held" + ] + } + }, + { + "facts": { + "number": 1088, + "state": "open", + "draft": false, + "author": "user-9", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000028", + "created_at": "2026-10-17T15:11:12+00:00", + "updated_at": "2026-10-24T03:34:29+00:00", + "labels": [], + "paths": [ + "tools/f0.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1011, + "kind": "contribute", + "is_pull": false, + "state": "open", + "labels": [ + "scope:deferred", + "type:proposal" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1075, + "kind": "none", + "is_pull": false, + "state": null, + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": true + }, + { + "number": 1009, + "kind": "contribute", + "is_pull": false, + "state": "open", + "labels": [ + "priority:P1", + "scope:in-focus", + "status:accepted", + "type:proposal" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": "2026-08-21T19:39:16+00:00", + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": "2026-10-22T16:59:59+00:00", + "bot_comment": null, + "auto_merge": false, + "author_record": false, + "held_use": { + "1009": [ + 1061, + 1066, + 1086, + 1087, + 1088, + 1091 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "not_held" + ] + } + }, + { + "facts": { + "number": 1090, + "state": "open", + "draft": false, + "author": "user-16", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000029", + "created_at": "2026-10-18T02:17:22+00:00", + "updated_at": "2026-10-18T11:42:53+00:00", + "labels": [], + "paths": [ + "tools/f0.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1009, + "kind": "none", + "is_pull": false, + "state": "open", + "labels": [ + "priority:P1", + "scope:in-focus", + "status:accepted", + "type:proposal" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": "2026-10-17T01:10:19+00:00", + "unresolved": false + }, + { + "number": 1089, + "kind": "none", + "is_pull": false, + "state": null, + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": true + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": null, + "auto_merge": false, + "author_record": false, + "held_use": { + "1009": [ + 1090 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "not_held" + ] + } + }, + { + "facts": { + "number": 1091, + "state": "open", + "draft": false, + "author": "user-9", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "000000000000000000000000000000000000002a", + "created_at": "2026-10-18T05:58:59+00:00", + "updated_at": "2026-10-18T08:48:17+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py", + "tools/f3.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1090, + "kind": "contribute", + "is_pull": true, + "state": "open", + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1087, + "kind": "none", + "is_pull": true, + "state": "open", + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1053, + "kind": "none", + "is_pull": false, + "state": "closed", + "labels": [ + "scope:in-focus", + "status:accepted" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1054, + "kind": "none", + "is_pull": false, + "state": null, + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": true + }, + { + "number": 1009, + "kind": "contribute", + "is_pull": false, + "state": "open", + "labels": [ + "priority:P1", + "scope:in-focus", + "status:accepted", + "type:proposal" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": "2026-08-21T19:39:16+00:00", + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": null, + "auto_merge": false, + "author_record": false, + "held_use": { + "1009": [ + 1061, + 1066, + 1086, + 1087, + 1088, + 1091 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "closed_accepted", + "not_held" + ] + } + }, + { + "facts": { + "number": 1092, + "state": "open", + "draft": false, + "author": "dependabot[bot]", + "author_is_bot": true, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "000000000000000000000000000000000000002b", + "created_at": "2026-10-19T14:15:51+00:00", + "updated_at": "2026-10-19T14:16:17+00:00", + "labels": [ + "dependencies" + ], + "paths": [ + "tools/f0.py" + ], + "paths_truncated": false, + "references": [], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": "2026-10-19T14:15:56+00:00", + "bot_comment": null, + "auto_merge": false, + "author_record": false, + "held_use": {}, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "skip", + "route": null, + "reasons": [] + } + }, + { + "facts": { + "number": 1093, + "state": "open", + "draft": false, + "author": "dependabot[bot]", + "author_is_bot": true, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "000000000000000000000000000000000000002c", + "created_at": "2026-10-19T14:17:08+00:00", + "updated_at": "2026-10-19T17:14:34+00:00", + "labels": [ + "dependencies", + "status:needs-triage" + ], + "paths": [ + "tools/f0.py" + ], + "paths_truncated": false, + "references": [], + "references_dropped": 0, + "reviews": [ + { + "login": "user-17", + "state": "APPROVED", + "association": "NONE", + "submitted_at": "2026-10-19T17:12:45+00:00", + "commit_id": "000000000000000000000000000000000000002c" + }, + { + "login": "user-17", + "state": "APPROVED", + "association": "NONE", + "submitted_at": "2026-10-19T17:14:33+00:00", + "commit_id": "000000000000000000000000000000000000002c" + } + ], + "last_unlabel": null, + "last_author_comment_at": "2026-10-19T14:17:12+00:00", + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": {}, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "skip", + "route": null, + "reasons": [] + } + }, + { + "facts": { + "number": 1094, + "state": "open", + "draft": false, + "author": "dependabot[bot]", + "author_is_bot": true, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "000000000000000000000000000000000000002d", + "created_at": "2026-10-19T14:17:18+00:00", + "updated_at": "2026-10-19T14:17:35+00:00", + "labels": [ + "dependencies", + "status:needs-triage" + ], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py" + ], + "paths_truncated": false, + "references": [], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": "2026-10-19T14:17:22+00:00", + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": {}, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "skip", + "route": null, + "reasons": [] + } + }, + { + "facts": { + "number": 1099, + "state": "open", + "draft": false, + "author": "dependabot[bot]", + "author_is_bot": true, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "000000000000000000000000000000000000002e", + "created_at": "2026-10-26T09:41:22+00:00", + "updated_at": "2026-10-26T09:41:36+00:00", + "labels": [ + "dependencies", + "status:needs-triage" + ], + "paths": [ + "tools/f0.py" + ], + "paths_truncated": false, + "references": [], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": "2026-10-26T09:41:26+00:00", + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": {}, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "skip", + "route": null, + "reasons": [] + } + }, + { + "facts": { + "number": 1100, + "state": "open", + "draft": false, + "author": "dependabot[bot]", + "author_is_bot": true, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "000000000000000000000000000000000000002f", + "created_at": "2026-10-26T14:17:26+00:00", + "updated_at": "2026-10-26T14:17:42+00:00", + "labels": [ + "dependencies", + "status:needs-triage" + ], + "paths": [ + "tools/f0.py", + "tools/f1.py" + ], + "paths_truncated": false, + "references": [], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": "2026-10-26T14:17:30+00:00", + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": {}, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "skip", + "route": null, + "reasons": [] + } + }, + { + "facts": { + "number": 1101, + "state": "open", + "draft": false, + "author": "dependabot[bot]", + "author_is_bot": true, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000030", + "created_at": "2026-10-26T14:17:36+00:00", + "updated_at": "2026-10-26T14:17:50+00:00", + "labels": [ + "dependencies", + "status:needs-triage" + ], + "paths": [ + "tools/f0.py", + "tools/f1.py" + ], + "paths_truncated": false, + "references": [], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": "2026-10-26T14:17:40+00:00", + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": {}, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "skip", + "route": null, + "reasons": [] + } + }, + { + "facts": { + "number": 1103, + "state": "open", + "draft": false, + "author": "user-18", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000031", + "created_at": "2026-10-27T23:14:49+00:00", + "updated_at": "2026-10-27T23:15:02+00:00", + "labels": [ + "status:needs-triage" + ], + "paths": [ + "tools/f0.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1102, + "kind": "close", + "is_pull": false, + "state": null, + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": true + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": {}, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "no_accepted" + ] + } + }, + { + "facts": { + "number": 1104, + "state": "open", + "draft": false, + "author": "user-19", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000032", + "created_at": "2026-10-28T04:00:49+00:00", + "updated_at": "2026-10-29T16:58:10+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py", + "tools/f3.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1035, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "scope:in-focus", + "status:accepted", + "type:bug" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1044, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "scope:in-focus", + "status:accepted", + "type:bug" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": { + "1035": [ + 1104 + ], + "1044": [ + 1104 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "not_held" + ] + } + }, + { + "facts": { + "number": 1105, + "state": "open", + "draft": false, + "author": "user-19", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000033", + "created_at": "2026-10-28T04:05:26+00:00", + "updated_at": "2026-10-29T16:58:08+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py", + "tools/f3.py", + "tools/f4.py", + "tools/f5.py", + "tools/f6.py", + "tools/f7.py", + "tools/f8.py", + "tools/f9.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1078, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "priority:P2", + "scope:in-focus", + "status:accepted", + "type:bug", + "workstream:spec" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1079, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "priority:P2", + "scope:in-focus", + "status:accepted", + "type:bug", + "workstream:spec" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": { + "1078": [ + 1105 + ], + "1079": [ + 1105 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "not_held" + ] + } + }, + { + "facts": { + "number": 1107, + "state": "open", + "draft": false, + "author": "user-19", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000034", + "created_at": "2026-10-28T04:09:17+00:00", + "updated_at": "2026-10-29T16:58:05+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1026, + "kind": "contribute", + "is_pull": false, + "state": "open", + "labels": [ + "help wanted", + "priority:P1", + "scope:in-focus", + "status:accepted", + "workstream:coding-agents" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1106, + "kind": "close", + "is_pull": false, + "state": null, + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": true + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": null, + "auto_merge": false, + "author_record": false, + "held_use": { + "1026": [ + 1107 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "not_held" + ] + } + }, + { + "facts": { + "number": 1108, + "state": "open", + "draft": false, + "author": "user-19", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000035", + "created_at": "2026-10-28T05:13:27+00:00", + "updated_at": "2026-10-29T16:58:03+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1031, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "scope:in-focus", + "status:accepted", + "type:bug" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": { + "1031": [ + 1108 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "not_held" + ] + } + }, + { + "facts": { + "number": 1109, + "state": "open", + "draft": false, + "author": "user-19", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000036", + "created_at": "2026-10-28T05:15:23+00:00", + "updated_at": "2026-10-29T16:58:00+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1055, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "scope:in-focus", + "status:accepted" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": { + "1055": [ + 1109 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "not_held" + ] + } + }, + { + "facts": { + "number": 1110, + "state": "open", + "draft": false, + "author": "user-19", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000037", + "created_at": "2026-10-28T05:17:52+00:00", + "updated_at": "2026-10-29T16:57:58+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py", + "tools/f3.py", + "tools/f4.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1042, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "scope:in-focus", + "status:accepted", + "type:bug" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": { + "1042": [ + 1110 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "not_held" + ] + } + }, + { + "facts": { + "number": 1111, + "state": "open", + "draft": false, + "author": "user-19", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000038", + "created_at": "2026-10-28T05:19:30+00:00", + "updated_at": "2026-10-29T16:57:55+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1041, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "scope:in-focus", + "status:accepted", + "type:bug" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": { + "1041": [ + 1111 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "not_held" + ] + } + }, + { + "facts": { + "number": 1112, + "state": "open", + "draft": false, + "author": "user-19", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000039", + "created_at": "2026-10-28T05:21:01+00:00", + "updated_at": "2026-10-29T16:57:53+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1010, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "priority:P1", + "scope:in-focus", + "status:accepted", + "type:bug" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": { + "1010": [ + 1112 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "not_held" + ] + } + }, + { + "facts": { + "number": 1113, + "state": "open", + "draft": false, + "author": "user-19", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "000000000000000000000000000000000000003a", + "created_at": "2026-10-28T05:26:04+00:00", + "updated_at": "2026-10-29T16:57:51+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py", + "tools/f3.py", + "tools/f4.py", + "tools/f5.py", + "tools/f6.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1014, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "priority:P1", + "scope:in-focus", + "status:accepted", + "type:bug", + "workstream:sdk" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": { + "1014": [ + 1113 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "not_held" + ] + } + }, + { + "facts": { + "number": 1114, + "state": "open", + "draft": false, + "author": "user-19", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "000000000000000000000000000000000000003b", + "created_at": "2026-10-28T05:28:33+00:00", + "updated_at": "2026-10-29T16:57:49+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1015, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "priority:P2", + "scope:in-focus", + "status:accepted", + "type:bug", + "workstream:sdk" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": { + "1015": [ + 1114 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "not_held" + ] + } + }, + { + "facts": { + "number": 1115, + "state": "open", + "draft": false, + "author": "user-19", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "000000000000000000000000000000000000003c", + "created_at": "2026-10-28T05:31:52+00:00", + "updated_at": "2026-10-29T16:57:46+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py", + "tools/f3.py", + "tools/f4.py", + "tools/f5.py", + "tools/f6.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1016, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "priority:P2", + "scope:in-focus", + "status:accepted", + "type:bug", + "workstream:sdk" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": { + "1016": [ + 1115 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "not_held" + ] + } + }, + { + "facts": { + "number": 1116, + "state": "open", + "draft": false, + "author": "user-19", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "000000000000000000000000000000000000003d", + "created_at": "2026-10-28T05:36:00+00:00", + "updated_at": "2026-10-29T16:57:44+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py", + "tools/f3.py", + "tools/f4.py", + "tools/f5.py", + "tools/f6.py", + "tools/f7.py", + "tools/f8.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1040, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "scope:in-focus", + "status:accepted", + "type:bug" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1059, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "scope:in-focus", + "status:accepted", + "type:conformance" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": { + "1040": [ + 1116 + ], + "1059": [ + 1116 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "not_held" + ] + } + }, + { + "facts": { + "number": 1117, + "state": "open", + "draft": false, + "author": "user-19", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "000000000000000000000000000000000000003e", + "created_at": "2026-10-28T05:38:31+00:00", + "updated_at": "2026-10-29T16:57:41+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py", + "tools/f3.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1039, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "scope:in-focus", + "status:accepted", + "type:bug" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": { + "1039": [ + 1117 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "not_held" + ] + } + }, + { + "facts": { + "number": 1118, + "state": "open", + "draft": false, + "author": "user-19", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "000000000000000000000000000000000000003f", + "created_at": "2026-10-28T05:40:41+00:00", + "updated_at": "2026-10-29T16:57:39+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py", + "tools/f3.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1038, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "scope:in-focus", + "status:accepted", + "type:bug" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1045, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "scope:in-focus", + "status:accepted", + "type:bug" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": { + "1038": [ + 1118 + ], + "1045": [ + 1118 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "not_held" + ] + } + }, + { + "facts": { + "number": 1119, + "state": "open", + "draft": false, + "author": "user-19", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000040", + "created_at": "2026-10-28T05:44:36+00:00", + "updated_at": "2026-10-29T16:57:36+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py", + "tools/f3.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1084, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "priority:P2", + "scope:in-focus", + "status:accepted", + "type:refimpl", + "workstream:refimpl" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": { + "1084": [ + 1119 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "not_held" + ] + } + }, + { + "facts": { + "number": 1121, + "state": "open", + "draft": false, + "author": "user-20", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000041", + "created_at": "2026-10-29T07:57:11+00:00", + "updated_at": "2026-10-29T10:30:16+00:00", + "labels": [ + "status:needs-triage" + ], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1096, + "kind": "contribute", + "is_pull": false, + "state": null, + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": true + }, + { + "number": 1097, + "kind": "contribute", + "is_pull": false, + "state": null, + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": true + }, + { + "number": 1109, + "kind": "none", + "is_pull": true, + "state": "open", + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": {}, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "no_accepted" + ] + } + }, + { + "facts": { + "number": 1122, + "state": "open", + "draft": false, + "author": "user-21", + "author_is_bot": false, + "title": "t", + "body": "- [x] This is an editorial correction on the documentation lane (typo, grammar, link, formatting)", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000042", + "created_at": "2026-10-29T11:54:39+00:00", + "updated_at": "2026-10-29T11:54:47+00:00", + "labels": [], + "paths": [ + "tools/f0.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1026, + "kind": "contribute", + "is_pull": false, + "state": "open", + "labels": [ + "help wanted", + "priority:P1", + "scope:in-focus", + "status:accepted", + "workstream:coding-agents" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": "2026-10-27T13:37:35+00:00", + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": null, + "auto_merge": false, + "author_record": false, + "held_use": { + "1026": [ + 1122 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "editorial_off_lane" + ] + } + }, + { + "facts": { + "number": 1123, + "state": "open", + "draft": false, + "author": "lead-12", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000043", + "created_at": "2026-10-29T13:57:02+00:00", + "updated_at": "2026-10-29T13:57:18+00:00", + "labels": [ + "status:needs-triage" + ], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py", + "tools/f3.py", + "tools/f4.py", + "tools/f5.py", + "tools/f6.py", + "tools/f7.py", + "tools/f8.py", + "tools/f9.py", + "tools/f10.py", + "tools/f11.py", + "tools/f12.py", + "tools/f13.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1120, + "kind": "close", + "is_pull": false, + "state": "open", + "labels": [ + "priority:P1", + "scope:in-focus", + "status:accepted", + "type:bug", + "workstream:refimpl" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1062, + "kind": "none", + "is_pull": true, + "state": "open", + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": false + }, + { + "number": 1095, + "kind": "none", + "is_pull": false, + "state": null, + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": true + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": { + "id": 1, + "body": "", + "data": null, + "legacy": true + }, + "auto_merge": false, + "author_record": false, + "held_use": { + "1120": [ + 1123 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "pass", + "route": "roster", + "reasons": [] + } + }, + { + "facts": { + "number": 1124, + "state": "open", + "draft": false, + "author": "user-22", + "author_is_bot": false, + "title": "t", + "body": "b", + "base": "integration", + "head_sha": "0000000000000000000000000000000000000044", + "created_at": "2026-10-29T20:32:19+00:00", + "updated_at": "2026-10-29T20:41:46+00:00", + "labels": [], + "paths": [ + "tools/f0.py", + "tools/f1.py", + "tools/f2.py", + "tools/f3.py", + "tools/f4.py", + "tools/f5.py", + "tools/f6.py", + "tools/f7.py" + ], + "paths_truncated": false, + "references": [ + { + "number": 1098, + "kind": "contribute", + "is_pull": false, + "state": "open", + "labels": [ + "priority:P2", + "scope:in-focus", + "status:accepted", + "type:refimpl", + "workstream:refimpl" + ], + "assignees": [], + "milestoned": true, + "author_commented_at": "2026-10-26T19:00:44+00:00", + "unresolved": false + }, + { + "number": 1062, + "kind": "none", + "is_pull": true, + "state": "open", + "labels": [], + "assignees": [], + "milestoned": false, + "author_commented_at": null, + "unresolved": false + } + ], + "references_dropped": 0, + "reviews": [], + "last_unlabel": null, + "last_author_comment_at": null, + "bot_comment": null, + "auto_merge": false, + "author_record": false, + "held_use": { + "1098": [ + 1124 + ] + }, + "roster": [ + "lead-1", + "lead-10", + "lead-11", + "lead-12", + "lead-13", + "lead-14", + "lead-15", + "lead-2", + "lead-3", + "lead-4", + "lead-5", + "lead-6", + "lead-7", + "lead-8", + "lead-9" + ], + "mode": "enforced", + "sweep": true, + "event_action": null + }, + "today": "2026-10-29", + "expected": { + "outcome": "queue", + "route": null, + "reasons": [ + "not_held" + ] + } + } + ] +} diff --git a/tests/fixtures/pr_intake/pull.json b/tests/fixtures/pr_intake/pull.json new file mode 100644 index 00000000..8a566e73 --- /dev/null +++ b/tests/fixtures/pr_intake/pull.json @@ -0,0 +1,585 @@ +{ + "_links": { + "comments": { + "href": "https://api.github.com/scrubbed" + }, + "commits": { + "href": "https://api.github.com/scrubbed" + }, + "html": { + "href": "https://api.github.com/scrubbed" + }, + "issue": { + "href": "https://api.github.com/scrubbed" + }, + "review_comment": { + "href": "https://api.github.com/scrubbed" + }, + "review_comments": { + "href": "https://api.github.com/scrubbed" + }, + "self": { + "href": "https://api.github.com/scrubbed" + }, + "statuses": { + "href": "https://api.github.com/scrubbed" + } + }, + "active_lock_reason": null, + "additions": 2880, + "assignee": null, + "assignees": [], + "author_association": "FIRST_TIME_CONTRIBUTOR", + "auto_merge": null, + "base": { + "label": "scrubbed", + "ref": "integration", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-04-10T22:30:37Z", + "default_branch": "integration", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": false, + "forks": 78, + "forks_count": 78, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": true, + "has_downloads": false, + "has_issues": true, + "has_pages": true, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1207394925, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": "TypeScript", + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOR_dibQ", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 204, + "open_issues_count": 204, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-29T14:49:44Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17260, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 228, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [ + "agent-safety", + "ai-agents", + "governance", + "middleware", + "observability", + "open-standard", + "opentelemetry", + "owasp", + "policy-enforcement", + "runtime-control" + ], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-10-29T16:21:05Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 228, + "watchers_count": 228, + "web_commit_signoff_required": true + }, + "sha": "599b1a33b178668ac4d663cdfa14f211588e9012", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "body": "scrubbed", + "changed_files": 56, + "closed_at": null, + "comments": 20, + "comments_url": "https://api.github.com/scrubbed", + "commits": 15, + "commits_url": "https://api.github.com/scrubbed", + "created_at": "2026-10-06T06:09:01Z", + "deletions": 491, + "diff_url": "https://api.github.com/scrubbed", + "draft": false, + "head": { + "label": "scrubbed", + "ref": "scrubbed", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-07-19T23:36:35Z", + "default_branch": "main", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": true, + "forks": 0, + "forks_count": 0, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": false, + "has_downloads": false, + "has_issues": false, + "has_pages": false, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1306073577, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": "Python", + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOTdkZ6Q", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 0, + "open_issues_count": 0, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-28T19:29:52Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17223, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 0, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-09-09T18:20:01Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 0, + "watchers_count": 0, + "web_commit_signoff_required": true + }, + "sha": "9d3efa31a6ace6595a0e0f42f894a70af943da49", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "html_url": "https://api.github.com/scrubbed", + "id": 4754866764, + "issue_url": "https://api.github.com/scrubbed", + "labels": [], + "locked": false, + "maintainer_can_modify": true, + "merge_commit_sha": "7d02f2a926f15ea72d6d8250421cad3f320f04fd", + "mergeable": true, + "mergeable_state": "blocked", + "merged": false, + "merged_at": null, + "merged_by": null, + "milestone": null, + "node_id": "PR_kwDOR_dibc8AAAABG2mCTA", + "number": 229, + "patch_url": "https://api.github.com/scrubbed", + "rebaseable": false, + "requested_reviewers": [ + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-2", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-3", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-4", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-5", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-6", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-7", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-8", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-9", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-10", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-11", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + ], + "requested_teams": [], + "review_comment_url": "https://api.github.com/scrubbed", + "review_comments": 15, + "review_comments_url": "https://api.github.com/scrubbed", + "state": "open", + "statuses_url": "https://api.github.com/scrubbed", + "title": "scrubbed", + "updated_at": "2026-10-29T14:21:37Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } +} diff --git a/tests/fixtures/pr_intake/pulls_closed.json b/tests/fixtures/pr_intake/pulls_closed.json new file mode 100644 index 00000000..66725d96 --- /dev/null +++ b/tests/fixtures/pr_intake/pulls_closed.json @@ -0,0 +1,3217 @@ +[ + { + "_links": { + "comments": { + "href": "https://api.github.com/scrubbed" + }, + "commits": { + "href": "https://api.github.com/scrubbed" + }, + "html": { + "href": "https://api.github.com/scrubbed" + }, + "issue": { + "href": "https://api.github.com/scrubbed" + }, + "review_comment": { + "href": "https://api.github.com/scrubbed" + }, + "review_comments": { + "href": "https://api.github.com/scrubbed" + }, + "self": { + "href": "https://api.github.com/scrubbed" + }, + "statuses": { + "href": "https://api.github.com/scrubbed" + } + }, + "active_lock_reason": null, + "assignee": null, + "assignees": [], + "author_association": "MEMBER", + "auto_merge": null, + "base": { + "label": "scrubbed", + "ref": "main", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-04-10T22:30:37Z", + "default_branch": "integration", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": false, + "forks": 78, + "forks_count": 78, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": true, + "has_downloads": false, + "has_issues": true, + "has_pages": true, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1207394925, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": "TypeScript", + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOR_dibQ", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 204, + "open_issues_count": 204, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-29T14:49:44Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17260, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 228, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [ + "agent-safety", + "ai-agents", + "governance", + "middleware", + "observability", + "open-standard", + "opentelemetry", + "owasp", + "policy-enforcement", + "runtime-control" + ], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-10-29T16:21:05Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 228, + "watchers_count": 228, + "web_commit_signoff_required": true + }, + "sha": "818302949a377564d421c11e5f7da9d3638913fc", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "body": "scrubbed", + "closed_at": "2026-10-29T14:49:44Z", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "created_at": "2026-10-29T14:48:08Z", + "diff_url": "https://api.github.com/scrubbed", + "draft": false, + "head": { + "label": "scrubbed", + "ref": "integration", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-04-10T22:30:37Z", + "default_branch": "integration", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": false, + "forks": 78, + "forks_count": 78, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": true, + "has_downloads": false, + "has_issues": true, + "has_pages": true, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1207394925, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": "TypeScript", + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOR_dibQ", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 204, + "open_issues_count": 204, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-29T14:49:44Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17260, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 228, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [ + "agent-safety", + "ai-agents", + "governance", + "middleware", + "observability", + "open-standard", + "opentelemetry", + "owasp", + "policy-enforcement", + "runtime-control" + ], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-10-29T16:21:05Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 228, + "watchers_count": 228, + "web_commit_signoff_required": true + }, + "sha": "b383e40ff7eb475f6decf0e8f5cde28efddd2f35", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "html_url": "https://api.github.com/scrubbed", + "id": 5070179857, + "issue_url": "https://api.github.com/scrubbed", + "labels": [ + { + "archived_at": null, + "archived_by": null, + "color": "ededed", + "default": false, + "description": "scrubbed", + "id": 12125610410, + "name": "status:needs-triage", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hqg", + "url": "https://api.github.com/scrubbed" + } + ], + "locked": false, + "merge_commit_sha": "4d325d786fdb55e2ef0af9ebc7e8587171c14059", + "merged_at": "2026-10-29T14:49:44Z", + "milestone": null, + "node_id": "PR_kwDOR_dibc8AAAABLjTOEQ", + "number": 358, + "patch_url": "https://api.github.com/scrubbed", + "requested_reviewers": [ + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-3", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-4", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-7", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-8", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-9", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-11", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + ], + "requested_teams": [], + "review_comment_url": "https://api.github.com/scrubbed", + "review_comments_url": "https://api.github.com/scrubbed", + "state": "closed", + "statuses_url": "https://api.github.com/scrubbed", + "title": "scrubbed", + "updated_at": "2026-10-29T14:49:44Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "_links": { + "comments": { + "href": "https://api.github.com/scrubbed" + }, + "commits": { + "href": "https://api.github.com/scrubbed" + }, + "html": { + "href": "https://api.github.com/scrubbed" + }, + "issue": { + "href": "https://api.github.com/scrubbed" + }, + "review_comment": { + "href": "https://api.github.com/scrubbed" + }, + "review_comments": { + "href": "https://api.github.com/scrubbed" + }, + "self": { + "href": "https://api.github.com/scrubbed" + }, + "statuses": { + "href": "https://api.github.com/scrubbed" + } + }, + "active_lock_reason": null, + "assignee": null, + "assignees": [], + "author_association": "MEMBER", + "auto_merge": null, + "base": { + "label": "scrubbed", + "ref": "integration", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-04-10T22:30:37Z", + "default_branch": "integration", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": false, + "forks": 78, + "forks_count": 78, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": true, + "has_downloads": false, + "has_issues": true, + "has_pages": true, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1207394925, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": "TypeScript", + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOR_dibQ", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 204, + "open_issues_count": 204, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-29T14:49:44Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17260, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 228, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [ + "agent-safety", + "ai-agents", + "governance", + "middleware", + "observability", + "open-standard", + "opentelemetry", + "owasp", + "policy-enforcement", + "runtime-control" + ], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-10-29T16:21:05Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 228, + "watchers_count": 228, + "web_commit_signoff_required": true + }, + "sha": "65813c7606dd931a6bb459d5a11c4417d14024e3", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "body": "scrubbed", + "closed_at": "2026-10-29T14:47:53Z", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "created_at": "2026-10-29T14:45:55Z", + "diff_url": "https://api.github.com/scrubbed", + "draft": false, + "head": { + "label": "scrubbed", + "ref": "scrubbed", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-04-10T22:30:37Z", + "default_branch": "integration", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": false, + "forks": 78, + "forks_count": 78, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": true, + "has_downloads": false, + "has_issues": true, + "has_pages": true, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1207394925, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": "TypeScript", + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOR_dibQ", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 204, + "open_issues_count": 204, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-29T14:49:44Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17260, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 228, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [ + "agent-safety", + "ai-agents", + "governance", + "middleware", + "observability", + "open-standard", + "opentelemetry", + "owasp", + "policy-enforcement", + "runtime-control" + ], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-10-29T16:21:05Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 228, + "watchers_count": 228, + "web_commit_signoff_required": true + }, + "sha": "99c6d5e51205df885fc9db9e0b7448f687e96681", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "html_url": "https://api.github.com/scrubbed", + "id": 5070142940, + "issue_url": "https://api.github.com/scrubbed", + "labels": [], + "locked": false, + "merge_commit_sha": "b383e40ff7eb475f6decf0e8f5cde28efddd2f35", + "merged_at": "2026-10-29T14:47:53Z", + "milestone": null, + "node_id": "PR_kwDOR_dibc8AAAABLjQ93A", + "number": 357, + "patch_url": "https://api.github.com/scrubbed", + "requested_reviewers": [ + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-3", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-4", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-7", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-8", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-9", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-11", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + ], + "requested_teams": [], + "review_comment_url": "https://api.github.com/scrubbed", + "review_comments_url": "https://api.github.com/scrubbed", + "state": "closed", + "statuses_url": "https://api.github.com/scrubbed", + "title": "scrubbed", + "updated_at": "2026-10-29T14:47:53Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "_links": { + "comments": { + "href": "https://api.github.com/scrubbed" + }, + "commits": { + "href": "https://api.github.com/scrubbed" + }, + "html": { + "href": "https://api.github.com/scrubbed" + }, + "issue": { + "href": "https://api.github.com/scrubbed" + }, + "review_comment": { + "href": "https://api.github.com/scrubbed" + }, + "review_comments": { + "href": "https://api.github.com/scrubbed" + }, + "self": { + "href": "https://api.github.com/scrubbed" + }, + "statuses": { + "href": "https://api.github.com/scrubbed" + } + }, + "active_lock_reason": null, + "assignee": null, + "assignees": [], + "author_association": "MEMBER", + "auto_merge": null, + "base": { + "label": "scrubbed", + "ref": "main", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-04-10T22:30:37Z", + "default_branch": "integration", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": false, + "forks": 78, + "forks_count": 78, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": true, + "has_downloads": false, + "has_issues": true, + "has_pages": true, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1207394925, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": "TypeScript", + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOR_dibQ", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 204, + "open_issues_count": 204, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-29T14:49:44Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17260, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 228, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [ + "agent-safety", + "ai-agents", + "governance", + "middleware", + "observability", + "open-standard", + "opentelemetry", + "owasp", + "policy-enforcement", + "runtime-control" + ], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-10-29T16:21:05Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 228, + "watchers_count": 228, + "web_commit_signoff_required": true + }, + "sha": "b9e833a42286c3627c3e059ddb04192ec10a12fa", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "body": "scrubbed", + "closed_at": "2026-10-29T14:16:13Z", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "created_at": "2026-10-29T14:13:49Z", + "diff_url": "https://api.github.com/scrubbed", + "draft": false, + "head": { + "label": "scrubbed", + "ref": "integration", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-04-10T22:30:37Z", + "default_branch": "integration", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": false, + "forks": 78, + "forks_count": 78, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": true, + "has_downloads": false, + "has_issues": true, + "has_pages": true, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1207394925, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": "TypeScript", + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOR_dibQ", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 204, + "open_issues_count": 204, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-29T14:49:44Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17260, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 228, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [ + "agent-safety", + "ai-agents", + "governance", + "middleware", + "observability", + "open-standard", + "opentelemetry", + "owasp", + "policy-enforcement", + "runtime-control" + ], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-10-29T16:21:05Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 228, + "watchers_count": 228, + "web_commit_signoff_required": true + }, + "sha": "65813c7606dd931a6bb459d5a11c4417d14024e3", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "html_url": "https://api.github.com/scrubbed", + "id": 5069610508, + "issue_url": "https://api.github.com/scrubbed", + "labels": [], + "locked": false, + "merge_commit_sha": "818302949a377564d421c11e5f7da9d3638913fc", + "merged_at": "2026-10-29T14:16:13Z", + "milestone": null, + "node_id": "PR_kwDOR_dibc8AAAABLiweDA", + "number": 356, + "patch_url": "https://api.github.com/scrubbed", + "requested_reviewers": [ + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-2", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-3", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-4", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-5", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-6", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-7", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-8", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-9", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-10", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-11", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + ], + "requested_teams": [], + "review_comment_url": "https://api.github.com/scrubbed", + "review_comments_url": "https://api.github.com/scrubbed", + "state": "closed", + "statuses_url": "https://api.github.com/scrubbed", + "title": "scrubbed", + "updated_at": "2026-10-29T14:16:13Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "_links": { + "comments": { + "href": "https://api.github.com/scrubbed" + }, + "commits": { + "href": "https://api.github.com/scrubbed" + }, + "html": { + "href": "https://api.github.com/scrubbed" + }, + "issue": { + "href": "https://api.github.com/scrubbed" + }, + "review_comment": { + "href": "https://api.github.com/scrubbed" + }, + "review_comments": { + "href": "https://api.github.com/scrubbed" + }, + "self": { + "href": "https://api.github.com/scrubbed" + }, + "statuses": { + "href": "https://api.github.com/scrubbed" + } + }, + "active_lock_reason": null, + "assignee": null, + "assignees": [], + "author_association": "COLLABORATOR", + "auto_merge": null, + "base": { + "label": "scrubbed", + "ref": "integration", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-04-10T22:30:37Z", + "default_branch": "integration", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": false, + "forks": 78, + "forks_count": 78, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": true, + "has_downloads": false, + "has_issues": true, + "has_pages": true, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1207394925, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": "TypeScript", + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOR_dibQ", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 204, + "open_issues_count": 204, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-29T14:49:44Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17260, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 228, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [ + "agent-safety", + "ai-agents", + "governance", + "middleware", + "observability", + "open-standard", + "opentelemetry", + "owasp", + "policy-enforcement", + "runtime-control" + ], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-10-29T16:21:05Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 228, + "watchers_count": 228, + "web_commit_signoff_required": true + }, + "sha": "34e8ff70be93df11787c4a4315cb65bd63543417", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "body": "scrubbed", + "closed_at": "2026-10-29T14:12:01Z", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "created_at": "2026-06-07T15:26:42Z", + "diff_url": "https://api.github.com/scrubbed", + "draft": false, + "head": { + "label": "scrubbed", + "ref": "scrubbed", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-06-07T15:26:01Z", + "default_branch": "main", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": true, + "forks": 0, + "forks_count": 0, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": false, + "has_downloads": false, + "has_issues": false, + "has_pages": false, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1262105924, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": null, + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "mit", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTEz", + "spdx_id": "MIT", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOSzo1RA", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 0, + "open_issues_count": 0, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-11", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-28T12:15:36Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17109, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 0, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-08-12T00:46:36Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 0, + "watchers_count": 0, + "web_commit_signoff_required": true + }, + "sha": "c56e06a07c187988deb1fa5f54cd05de80ee138b", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-11", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "html_url": "https://api.github.com/scrubbed", + "id": 3819254189, + "issue_url": "https://api.github.com/scrubbed", + "labels": [], + "locked": false, + "merge_commit_sha": "65813c7606dd931a6bb459d5a11c4417d14024e3", + "merged_at": "2026-10-29T14:12:01Z", + "milestone": null, + "node_id": "PR_kwDOR_dibc7jpTGt", + "number": 20, + "patch_url": "https://api.github.com/scrubbed", + "requested_reviewers": [ + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-2", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-3", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-4", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-5", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-6", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-7", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-8", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-9", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + ], + "requested_teams": [], + "review_comment_url": "https://api.github.com/scrubbed", + "review_comments_url": "https://api.github.com/scrubbed", + "state": "closed", + "statuses_url": "https://api.github.com/scrubbed", + "title": "scrubbed", + "updated_at": "2026-10-29T14:12:01Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-11", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "_links": { + "comments": { + "href": "https://api.github.com/scrubbed" + }, + "commits": { + "href": "https://api.github.com/scrubbed" + }, + "html": { + "href": "https://api.github.com/scrubbed" + }, + "issue": { + "href": "https://api.github.com/scrubbed" + }, + "review_comment": { + "href": "https://api.github.com/scrubbed" + }, + "review_comments": { + "href": "https://api.github.com/scrubbed" + }, + "self": { + "href": "https://api.github.com/scrubbed" + }, + "statuses": { + "href": "https://api.github.com/scrubbed" + } + }, + "active_lock_reason": null, + "assignee": null, + "assignees": [], + "author_association": "CONTRIBUTOR", + "auto_merge": null, + "base": { + "label": "scrubbed", + "ref": "integration", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-04-10T22:30:37Z", + "default_branch": "integration", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": false, + "forks": 78, + "forks_count": 78, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": true, + "has_downloads": false, + "has_issues": true, + "has_pages": true, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1207394925, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": "TypeScript", + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOR_dibQ", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 204, + "open_issues_count": 204, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-29T14:49:44Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17260, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 228, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [ + "agent-safety", + "ai-agents", + "governance", + "middleware", + "observability", + "open-standard", + "opentelemetry", + "owasp", + "policy-enforcement", + "runtime-control" + ], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-10-29T16:21:05Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 228, + "watchers_count": 228, + "web_commit_signoff_required": true + }, + "sha": "9c05f0402d76dea6b67a12a93672c2131137e572", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "body": "scrubbed", + "closed_at": "2026-10-26T14:17:36Z", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "created_at": "2026-10-19T14:16:45Z", + "diff_url": "https://api.github.com/scrubbed", + "draft": false, + "head": { + "label": "scrubbed", + "ref": "scrubbed", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-04-10T22:30:37Z", + "default_branch": "integration", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": false, + "forks": 78, + "forks_count": 78, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": true, + "has_downloads": false, + "has_issues": true, + "has_pages": true, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1207394925, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": "TypeScript", + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOR_dibQ", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 204, + "open_issues_count": 204, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-29T14:49:44Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17260, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 228, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [ + "agent-safety", + "ai-agents", + "governance", + "middleware", + "observability", + "open-standard", + "opentelemetry", + "owasp", + "policy-enforcement", + "runtime-control" + ], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-10-29T16:21:05Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 228, + "watchers_count": 228, + "web_commit_signoff_required": true + }, + "sha": "c406deafe91a330867bbc2f2a98d5d2222abe0da", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "html_url": "https://api.github.com/scrubbed", + "id": 4922377364, + "issue_url": "https://api.github.com/scrubbed", + "labels": [ + { + "archived_at": null, + "archived_by": null, + "color": "0366d6", + "default": false, + "description": "scrubbed", + "id": 10999624684, + "name": "dependencies", + "node_id": "LA_kwDOR_dibc8AAAACj6Dz7A", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "ededed", + "default": false, + "description": "scrubbed", + "id": 12125610410, + "name": "status:needs-triage", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hqg", + "url": "https://api.github.com/scrubbed" + } + ], + "locked": false, + "merge_commit_sha": "f54f0c3c4292e754ec8a409656c66dad7335d4cc", + "merged_at": null, + "milestone": null, + "node_id": "PR_kwDOR_dibc8AAAABJWWElA", + "number": 301, + "patch_url": "https://api.github.com/scrubbed", + "requested_reviewers": [ + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-3", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-4", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-7", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-8", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-9", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-11", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + ], + "requested_teams": [], + "review_comment_url": "https://api.github.com/scrubbed", + "review_comments_url": "https://api.github.com/scrubbed", + "state": "closed", + "statuses_url": "https://api.github.com/scrubbed", + "title": "scrubbed", + "updated_at": "2026-10-26T14:17:41Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "dependabot[bot]", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Bot", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "_links": { + "comments": { + "href": "https://api.github.com/scrubbed" + }, + "commits": { + "href": "https://api.github.com/scrubbed" + }, + "html": { + "href": "https://api.github.com/scrubbed" + }, + "issue": { + "href": "https://api.github.com/scrubbed" + }, + "review_comment": { + "href": "https://api.github.com/scrubbed" + }, + "review_comments": { + "href": "https://api.github.com/scrubbed" + }, + "self": { + "href": "https://api.github.com/scrubbed" + }, + "statuses": { + "href": "https://api.github.com/scrubbed" + } + }, + "active_lock_reason": null, + "assignee": null, + "assignees": [], + "author_association": "CONTRIBUTOR", + "auto_merge": null, + "base": { + "label": "scrubbed", + "ref": "integration", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-04-10T22:30:37Z", + "default_branch": "integration", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": false, + "forks": 78, + "forks_count": 78, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": true, + "has_downloads": false, + "has_issues": true, + "has_pages": true, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1207394925, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": "TypeScript", + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOR_dibQ", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 204, + "open_issues_count": 204, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-29T14:49:44Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17260, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 228, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [ + "agent-safety", + "ai-agents", + "governance", + "middleware", + "observability", + "open-standard", + "opentelemetry", + "owasp", + "policy-enforcement", + "runtime-control" + ], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-10-29T16:21:05Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 228, + "watchers_count": 228, + "web_commit_signoff_required": true + }, + "sha": "193753778977e88c6573adbf7f98e42eacd2c15a", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "body": "scrubbed", + "closed_at": "2026-10-19T14:17:25Z", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "created_at": "2026-09-28T14:16:40Z", + "diff_url": "https://api.github.com/scrubbed", + "draft": false, + "head": { + "label": "scrubbed", + "ref": "scrubbed", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-04-10T22:30:37Z", + "default_branch": "integration", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": false, + "forks": 78, + "forks_count": 78, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": true, + "has_downloads": false, + "has_issues": true, + "has_pages": true, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1207394925, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": "TypeScript", + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOR_dibQ", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 204, + "open_issues_count": 204, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-29T14:49:44Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17260, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 228, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [ + "agent-safety", + "ai-agents", + "governance", + "middleware", + "observability", + "open-standard", + "opentelemetry", + "owasp", + "policy-enforcement", + "runtime-control" + ], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-10-29T16:21:05Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 228, + "watchers_count": 228, + "web_commit_signoff_required": true + }, + "sha": "fc28b0e6f59efe4306ff2b474aa79fa2aa947bbd", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "html_url": "https://api.github.com/scrubbed", + "id": 4664921398, + "issue_url": "https://api.github.com/scrubbed", + "labels": [ + { + "archived_at": null, + "archived_by": null, + "color": "0366d6", + "default": false, + "description": "scrubbed", + "id": 10999624684, + "name": "dependencies", + "node_id": "LA_kwDOR_dibc8AAAACj6Dz7A", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "ededed", + "default": false, + "description": "scrubbed", + "id": 12125610410, + "name": "status:needs-triage", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hqg", + "url": "https://api.github.com/scrubbed" + } + ], + "locked": false, + "merge_commit_sha": "248d3446e5d08cefa17b661a4380b79a009cd0be", + "merged_at": null, + "milestone": null, + "node_id": "PR_kwDOR_dibc8AAAABFg0NNg", + "number": 191, + "patch_url": "https://api.github.com/scrubbed", + "requested_reviewers": [ + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-3", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-4", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-7", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-8", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-9", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-11", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + ], + "requested_teams": [], + "review_comment_url": "https://api.github.com/scrubbed", + "review_comments_url": "https://api.github.com/scrubbed", + "state": "closed", + "statuses_url": "https://api.github.com/scrubbed", + "title": "scrubbed", + "updated_at": "2026-10-19T14:17:27Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "dependabot[bot]", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Bot", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + } +] diff --git a/tests/fixtures/pr_intake/pulls_open.json b/tests/fixtures/pr_intake/pulls_open.json new file mode 100644 index 00000000..466ec5e5 --- /dev/null +++ b/tests/fixtures/pr_intake/pulls_open.json @@ -0,0 +1,3360 @@ +[ + { + "_links": { + "comments": { + "href": "https://api.github.com/scrubbed" + }, + "commits": { + "href": "https://api.github.com/scrubbed" + }, + "html": { + "href": "https://api.github.com/scrubbed" + }, + "issue": { + "href": "https://api.github.com/scrubbed" + }, + "review_comment": { + "href": "https://api.github.com/scrubbed" + }, + "review_comments": { + "href": "https://api.github.com/scrubbed" + }, + "self": { + "href": "https://api.github.com/scrubbed" + }, + "statuses": { + "href": "https://api.github.com/scrubbed" + } + }, + "active_lock_reason": null, + "assignee": null, + "assignees": [], + "author_association": "FIRST_TIME_CONTRIBUTOR", + "auto_merge": null, + "base": { + "label": "scrubbed", + "ref": "integration", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-04-10T22:30:37Z", + "default_branch": "integration", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": false, + "forks": 78, + "forks_count": 78, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": true, + "has_downloads": false, + "has_issues": true, + "has_pages": true, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1207394925, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": "TypeScript", + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOR_dibQ", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 204, + "open_issues_count": 204, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-29T14:49:44Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17260, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 228, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [ + "agent-safety", + "ai-agents", + "governance", + "middleware", + "observability", + "open-standard", + "opentelemetry", + "owasp", + "policy-enforcement", + "runtime-control" + ], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-10-29T16:21:05Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 228, + "watchers_count": 228, + "web_commit_signoff_required": true + }, + "sha": "599b1a33b178668ac4d663cdfa14f211588e9012", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "body": "scrubbed", + "closed_at": null, + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "created_at": "2026-10-29T11:54:39Z", + "diff_url": "https://api.github.com/scrubbed", + "draft": false, + "head": { + "label": "scrubbed", + "ref": "scrubbed", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-10-29T11:52:23Z", + "default_branch": "integration", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": true, + "forks": 0, + "forks_count": 0, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": false, + "has_downloads": false, + "has_issues": false, + "has_pages": false, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1438350927, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": null, + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOVbt-Tw", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 0, + "open_issues_count": 0, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-17", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-29T11:54:08Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 16884, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 0, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-10-29T11:52:23Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 0, + "watchers_count": 0, + "web_commit_signoff_required": true + }, + "sha": "f423875405e39bcb588f5f5bb948dbf8ce55983d", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-17", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "html_url": "https://api.github.com/scrubbed", + "id": 5067509988, + "issue_url": "https://api.github.com/scrubbed", + "labels": [], + "locked": false, + "merge_commit_sha": "e640cfd558c6d8528ee85f8ab95795995c8b1299", + "merged_at": null, + "milestone": null, + "node_id": "PR_kwDOR_dibc8AAAABLgwQ5A", + "number": 354, + "patch_url": "https://api.github.com/scrubbed", + "requested_reviewers": [ + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-2", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-3", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-4", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-7", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-8", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-9", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-11", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + ], + "requested_teams": [], + "review_comment_url": "https://api.github.com/scrubbed", + "review_comments_url": "https://api.github.com/scrubbed", + "state": "open", + "statuses_url": "https://api.github.com/scrubbed", + "title": "scrubbed", + "updated_at": "2026-10-29T11:54:47Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-17", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "_links": { + "comments": { + "href": "https://api.github.com/scrubbed" + }, + "commits": { + "href": "https://api.github.com/scrubbed" + }, + "html": { + "href": "https://api.github.com/scrubbed" + }, + "issue": { + "href": "https://api.github.com/scrubbed" + }, + "review_comment": { + "href": "https://api.github.com/scrubbed" + }, + "review_comments": { + "href": "https://api.github.com/scrubbed" + }, + "self": { + "href": "https://api.github.com/scrubbed" + }, + "statuses": { + "href": "https://api.github.com/scrubbed" + } + }, + "active_lock_reason": null, + "assignee": null, + "assignees": [], + "author_association": "FIRST_TIME_CONTRIBUTOR", + "auto_merge": null, + "base": { + "label": "scrubbed", + "ref": "integration", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-04-10T22:30:37Z", + "default_branch": "integration", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": false, + "forks": 78, + "forks_count": 78, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": true, + "has_downloads": false, + "has_issues": true, + "has_pages": true, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1207394925, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": "TypeScript", + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOR_dibQ", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 204, + "open_issues_count": 204, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-29T14:49:44Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17260, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 228, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [ + "agent-safety", + "ai-agents", + "governance", + "middleware", + "observability", + "open-standard", + "opentelemetry", + "owasp", + "policy-enforcement", + "runtime-control" + ], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-10-29T16:21:05Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 228, + "watchers_count": 228, + "web_commit_signoff_required": true + }, + "sha": "599b1a33b178668ac4d663cdfa14f211588e9012", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "body": "scrubbed", + "closed_at": null, + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "created_at": "2026-10-29T07:57:11Z", + "diff_url": "https://api.github.com/scrubbed", + "draft": false, + "head": { + "label": "scrubbed", + "ref": "scrubbed", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-10-21T13:39:22Z", + "default_branch": "integration", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": true, + "forks": 0, + "forks_count": 0, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": false, + "has_downloads": false, + "has_issues": false, + "has_pages": false, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1428438438, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": null, + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOVSQ9pg", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 0, + "open_issues_count": 0, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-25", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-29T07:56:25Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17139, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 0, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-10-21T13:39:22Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 0, + "watchers_count": 0, + "web_commit_signoff_required": true + }, + "sha": "e1b2fca886798f4e57036dff7834dc0353358c47", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-25", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "html_url": "https://api.github.com/scrubbed", + "id": 5064405087, + "issue_url": "https://api.github.com/scrubbed", + "labels": [ + { + "archived_at": null, + "archived_by": null, + "color": "ededed", + "default": false, + "description": "scrubbed", + "id": 12125610410, + "name": "status:needs-triage", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hqg", + "url": "https://api.github.com/scrubbed" + } + ], + "locked": false, + "merge_commit_sha": "2b817648d6a703eb840818d3bbbb3d98fad8936e", + "merged_at": null, + "milestone": null, + "node_id": "PR_kwDOR_dibc8AAAABLdywXw", + "number": 352, + "patch_url": "https://api.github.com/scrubbed", + "requested_reviewers": [ + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-2", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-3", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-4", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-5", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-6", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-7", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-8", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-9", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-10", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-11", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + ], + "requested_teams": [], + "review_comment_url": "https://api.github.com/scrubbed", + "review_comments_url": "https://api.github.com/scrubbed", + "state": "open", + "statuses_url": "https://api.github.com/scrubbed", + "title": "scrubbed", + "updated_at": "2026-10-29T10:30:16Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-25", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "_links": { + "comments": { + "href": "https://api.github.com/scrubbed" + }, + "commits": { + "href": "https://api.github.com/scrubbed" + }, + "html": { + "href": "https://api.github.com/scrubbed" + }, + "issue": { + "href": "https://api.github.com/scrubbed" + }, + "review_comment": { + "href": "https://api.github.com/scrubbed" + }, + "review_comments": { + "href": "https://api.github.com/scrubbed" + }, + "self": { + "href": "https://api.github.com/scrubbed" + }, + "statuses": { + "href": "https://api.github.com/scrubbed" + } + }, + "active_lock_reason": null, + "assignee": null, + "assignees": [], + "author_association": "CONTRIBUTOR", + "auto_merge": null, + "base": { + "label": "scrubbed", + "ref": "integration", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-04-10T22:30:37Z", + "default_branch": "integration", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": false, + "forks": 78, + "forks_count": 78, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": true, + "has_downloads": false, + "has_issues": true, + "has_pages": true, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1207394925, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": "TypeScript", + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOR_dibQ", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 204, + "open_issues_count": 204, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-29T14:49:44Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17260, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 228, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [ + "agent-safety", + "ai-agents", + "governance", + "middleware", + "observability", + "open-standard", + "opentelemetry", + "owasp", + "policy-enforcement", + "runtime-control" + ], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-10-29T16:21:05Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 228, + "watchers_count": 228, + "web_commit_signoff_required": true + }, + "sha": "d1ad2943107601e3c09f8e0c8eaf57b78b38a01b", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "body": "scrubbed", + "closed_at": null, + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "created_at": "2026-10-26T14:17:36Z", + "diff_url": "https://api.github.com/scrubbed", + "draft": false, + "head": { + "label": "scrubbed", + "ref": "scrubbed", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-04-10T22:30:37Z", + "default_branch": "integration", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": false, + "forks": 78, + "forks_count": 78, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": true, + "has_downloads": false, + "has_issues": true, + "has_pages": true, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1207394925, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": "TypeScript", + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOR_dibQ", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 204, + "open_issues_count": 204, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-29T14:49:44Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17260, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 228, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [ + "agent-safety", + "ai-agents", + "governance", + "middleware", + "observability", + "open-standard", + "opentelemetry", + "owasp", + "policy-enforcement", + "runtime-control" + ], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-10-29T16:21:05Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 228, + "watchers_count": 228, + "web_commit_signoff_required": true + }, + "sha": "1e5d38ccaf790303fc2fc812b454e53dda623b6b", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "html_url": "https://api.github.com/scrubbed", + "id": 5018968817, + "issue_url": "https://api.github.com/scrubbed", + "labels": [ + { + "archived_at": null, + "archived_by": null, + "color": "0366d6", + "default": false, + "description": "scrubbed", + "id": 10999624684, + "name": "dependencies", + "node_id": "LA_kwDOR_dibc8AAAACj6Dz7A", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "ededed", + "default": false, + "description": "scrubbed", + "id": 12125610410, + "name": "status:needs-triage", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hqg", + "url": "https://api.github.com/scrubbed" + } + ], + "locked": false, + "merge_commit_sha": "1b800de4b283c0335ee70c421626d42c4cdab691", + "merged_at": null, + "milestone": null, + "node_id": "PR_kwDOR_dibc8AAAABKydi8Q", + "number": 329, + "patch_url": "https://api.github.com/scrubbed", + "requested_reviewers": [ + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-3", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-4", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-7", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-8", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-9", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-11", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + ], + "requested_teams": [], + "review_comment_url": "https://api.github.com/scrubbed", + "review_comments_url": "https://api.github.com/scrubbed", + "state": "open", + "statuses_url": "https://api.github.com/scrubbed", + "title": "scrubbed", + "updated_at": "2026-10-26T14:17:50Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "dependabot[bot]", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Bot", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "_links": { + "comments": { + "href": "https://api.github.com/scrubbed" + }, + "commits": { + "href": "https://api.github.com/scrubbed" + }, + "html": { + "href": "https://api.github.com/scrubbed" + }, + "issue": { + "href": "https://api.github.com/scrubbed" + }, + "review_comment": { + "href": "https://api.github.com/scrubbed" + }, + "review_comments": { + "href": "https://api.github.com/scrubbed" + }, + "self": { + "href": "https://api.github.com/scrubbed" + }, + "statuses": { + "href": "https://api.github.com/scrubbed" + } + }, + "active_lock_reason": null, + "assignee": null, + "assignees": [], + "author_association": "COLLABORATOR", + "auto_merge": null, + "base": { + "label": "scrubbed", + "ref": "integration", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-04-10T22:30:37Z", + "default_branch": "integration", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": false, + "forks": 78, + "forks_count": 78, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": true, + "has_downloads": false, + "has_issues": true, + "has_pages": true, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1207394925, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": "TypeScript", + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOR_dibQ", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 204, + "open_issues_count": 204, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-29T14:49:44Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17260, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 228, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [ + "agent-safety", + "ai-agents", + "governance", + "middleware", + "observability", + "open-standard", + "opentelemetry", + "owasp", + "policy-enforcement", + "runtime-control" + ], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-10-29T16:21:05Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 228, + "watchers_count": 228, + "web_commit_signoff_required": true + }, + "sha": "185ef4ea54641522c2285baef11a6f8c0e41e34c", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "body": "scrubbed", + "closed_at": null, + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "created_at": "2026-10-13T05:17:29Z", + "diff_url": "https://api.github.com/scrubbed", + "draft": false, + "head": { + "label": "scrubbed", + "ref": "scrubbed", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-09-19T03:32:14Z", + "default_branch": "integration", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": true, + "forks": 0, + "forks_count": 0, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": false, + "has_downloads": false, + "has_issues": false, + "has_pages": false, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1376681253, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": null, + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOUg59JQ", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 0, + "open_issues_count": 0, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-10", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-13T05:16:17Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17020, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 0, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-09-19T03:32:15Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 0, + "watchers_count": 0, + "web_commit_signoff_required": true + }, + "sha": "92c3128105e323febd68e50f40ab8606b6f2e16b", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-10", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "html_url": "https://api.github.com/scrubbed", + "id": 4838722549, + "issue_url": "https://api.github.com/scrubbed", + "labels": [], + "locked": false, + "merge_commit_sha": "e8cd566c749dd67779d9a1d8a973b6d4881f3c1d", + "merged_at": null, + "milestone": null, + "node_id": "PR_kwDOR_dibc8AAAABIGkL9Q", + "number": 271, + "patch_url": "https://api.github.com/scrubbed", + "requested_reviewers": [ + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-2", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-3", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-4", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-5", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-6", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-7", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-8", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-9", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-11", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + ], + "requested_teams": [], + "review_comment_url": "https://api.github.com/scrubbed", + "review_comments_url": "https://api.github.com/scrubbed", + "state": "open", + "statuses_url": "https://api.github.com/scrubbed", + "title": "scrubbed", + "updated_at": "2026-10-29T14:10:03Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-10", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "_links": { + "comments": { + "href": "https://api.github.com/scrubbed" + }, + "commits": { + "href": "https://api.github.com/scrubbed" + }, + "html": { + "href": "https://api.github.com/scrubbed" + }, + "issue": { + "href": "https://api.github.com/scrubbed" + }, + "review_comment": { + "href": "https://api.github.com/scrubbed" + }, + "review_comments": { + "href": "https://api.github.com/scrubbed" + }, + "self": { + "href": "https://api.github.com/scrubbed" + }, + "statuses": { + "href": "https://api.github.com/scrubbed" + } + }, + "active_lock_reason": null, + "assignee": null, + "assignees": [], + "author_association": "FIRST_TIME_CONTRIBUTOR", + "auto_merge": null, + "base": { + "label": "scrubbed", + "ref": "integration", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-04-10T22:30:37Z", + "default_branch": "integration", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": false, + "forks": 78, + "forks_count": 78, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": true, + "has_downloads": false, + "has_issues": true, + "has_pages": true, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1207394925, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": "TypeScript", + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOR_dibQ", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 204, + "open_issues_count": 204, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-29T14:49:44Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17260, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 228, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [ + "agent-safety", + "ai-agents", + "governance", + "middleware", + "observability", + "open-standard", + "opentelemetry", + "owasp", + "policy-enforcement", + "runtime-control" + ], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-10-29T16:21:05Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 228, + "watchers_count": 228, + "web_commit_signoff_required": true + }, + "sha": "599b1a33b178668ac4d663cdfa14f211588e9012", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "body": "scrubbed", + "closed_at": null, + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "created_at": "2026-10-06T06:09:01Z", + "diff_url": "https://api.github.com/scrubbed", + "draft": false, + "head": { + "label": "scrubbed", + "ref": "scrubbed", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-07-19T23:36:35Z", + "default_branch": "main", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": true, + "forks": 0, + "forks_count": 0, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": false, + "has_downloads": false, + "has_issues": false, + "has_pages": false, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1306073577, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": "Python", + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOTdkZ6Q", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 0, + "open_issues_count": 0, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-28T19:29:52Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17223, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 0, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-09-09T18:20:01Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 0, + "watchers_count": 0, + "web_commit_signoff_required": true + }, + "sha": "9d3efa31a6ace6595a0e0f42f894a70af943da49", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "html_url": "https://api.github.com/scrubbed", + "id": 4754866764, + "issue_url": "https://api.github.com/scrubbed", + "labels": [], + "locked": false, + "merge_commit_sha": "7d02f2a926f15ea72d6d8250421cad3f320f04fd", + "merged_at": null, + "milestone": null, + "node_id": "PR_kwDOR_dibc8AAAABG2mCTA", + "number": 229, + "patch_url": "https://api.github.com/scrubbed", + "requested_reviewers": [ + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-2", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-3", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-4", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-5", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-6", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-7", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-8", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-9", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-10", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-11", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + ], + "requested_teams": [], + "review_comment_url": "https://api.github.com/scrubbed", + "review_comments_url": "https://api.github.com/scrubbed", + "state": "open", + "statuses_url": "https://api.github.com/scrubbed", + "title": "scrubbed", + "updated_at": "2026-10-29T14:21:37Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "_links": { + "comments": { + "href": "https://api.github.com/scrubbed" + }, + "commits": { + "href": "https://api.github.com/scrubbed" + }, + "html": { + "href": "https://api.github.com/scrubbed" + }, + "issue": { + "href": "https://api.github.com/scrubbed" + }, + "review_comment": { + "href": "https://api.github.com/scrubbed" + }, + "review_comments": { + "href": "https://api.github.com/scrubbed" + }, + "self": { + "href": "https://api.github.com/scrubbed" + }, + "statuses": { + "href": "https://api.github.com/scrubbed" + } + }, + "active_lock_reason": null, + "assignee": null, + "assignees": [], + "author_association": "FIRST_TIME_CONTRIBUTOR", + "auto_merge": null, + "base": { + "label": "scrubbed", + "ref": "integration", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-04-10T22:30:37Z", + "default_branch": "integration", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": false, + "forks": 78, + "forks_count": 78, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": true, + "has_downloads": false, + "has_issues": true, + "has_pages": true, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1207394925, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": "TypeScript", + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOR_dibQ", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 204, + "open_issues_count": 204, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-29T14:49:44Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17260, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 228, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [ + "agent-safety", + "ai-agents", + "governance", + "middleware", + "observability", + "open-standard", + "opentelemetry", + "owasp", + "policy-enforcement", + "runtime-control" + ], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-10-29T16:21:05Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 228, + "watchers_count": 228, + "web_commit_signoff_required": true + }, + "sha": "dc265475139a922824f0c817e2ecc2a2ce31c06c", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "body": "scrubbed", + "closed_at": null, + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "created_at": "2026-07-19T23:53:36Z", + "diff_url": "https://api.github.com/scrubbed", + "draft": false, + "head": { + "label": "scrubbed", + "ref": "scrubbed", + "repo": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-07-19T23:36:35Z", + "default_branch": "main", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": true, + "forks": 0, + "forks_count": 0, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": false, + "has_downloads": false, + "has_issues": false, + "has_pages": false, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1306073577, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": "Python", + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOTdkZ6Q", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 0, + "open_issues_count": 0, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-28T19:29:52Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17223, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 0, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-09-09T18:20:01Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 0, + "watchers_count": 0, + "web_commit_signoff_required": true + }, + "sha": "c3c80b81785b4deb2f9ae23bd4550a73c54c3d83", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "html_url": "https://api.github.com/scrubbed", + "id": 4087551037, + "issue_url": "https://api.github.com/scrubbed", + "labels": [], + "locked": false, + "merge_commit_sha": "a5e2deff9cd7682ccb4f6116fe6f52602eb5ca9f", + "merged_at": null, + "milestone": null, + "node_id": "PR_kwDOR_dibc7zoxQ9", + "number": 24, + "patch_url": "https://api.github.com/scrubbed", + "requested_reviewers": [ + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-3", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-4", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-5", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-6", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-7", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-8", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-9", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-11", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + ], + "requested_teams": [], + "review_comment_url": "https://api.github.com/scrubbed", + "review_comments_url": "https://api.github.com/scrubbed", + "state": "open", + "statuses_url": "https://api.github.com/scrubbed", + "title": "scrubbed", + "updated_at": "2026-10-05T09:06:27Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + } +] diff --git a/tests/fixtures/pr_intake/record_population.py b/tests/fixtures/pr_intake/record_population.py new file mode 100644 index 00000000..31d29856 --- /dev/null +++ b/tests/fixtures/pr_intake/record_population.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Record population.json: every open pull request's gate facts, with nothing that names anyone. + +Version 1.0. Owner: ACS project leads. Read-only: it calls `gh api` reads only. + +Usage, from the repository root: + GH_TOKEN=$(gh auth token) python3 -I tests/fixtures/pr_intake/record_population.py --gh "$(command -v gh)" + +It loads the shared listings and collects every open pull request's facts through +`pr_intake.collect_facts`, in enforced mode, exactly as a sweep would. Each row is then +made anonymous before it is written: + +- Logins become `lead-N` for a roster login and `user-N` for anyone else. The two bots + the gate names keep their logins, since the gate reads them. +- Pull request and issue numbers become `1001` upward, in the same order, so the held + cap, which compares numbers, reads the same. +- SHAs become numbered hex strings, so a review's commit still matches the head or not. +- The title becomes `t`. The body becomes the template's ticked editorial line when the + box is ticked and `b` otherwise, the one thing the gate reads in it. +- Paths become synthetic paths, on the documentation lane exactly when the real one is. + +`expected` is `decide` on the anonymous facts, and the script refuses to write a row +whose anonymous decision differs from the decision on the real facts. The real numbers +are printed to the terminal for hand checks and never written to the file. +""" +from __future__ import annotations + +import argparse +import dataclasses +import json +import sys +from datetime import date, datetime +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT / "tools")) + +import base_branch_guard # noqa: E402 +import gh_runner # noqa: E402 +import pr_intake # noqa: E402 +import roadmap_model # noqa: E402 + +OUT = Path(__file__).resolve().parent / "population.json" +TICKED = "- [x] This is an editorial correction on the documentation lane (typo, grammar, link, formatting)" +KEPT = pr_intake.KNOWN_BOTS | {roadmap_model.BOT_LOGIN} + + +class Anonymizer: + def __init__(self, roster: frozenset[str], numbers: list[int]) -> None: + self.logins = {login: f"lead-{index}" for index, login in enumerate(sorted(roster), 1)} + self.others = 0 + self.numbers = {number: 1001 + index for index, number in enumerate(sorted(set(numbers)))} + self.shas: dict[str, str] = {} + + def login(self, login: str) -> str: + if login in KEPT: + return login + if login not in self.logins: + self.others += 1 + self.logins[login] = f"user-{self.others}" + return self.logins[login] + + def sha(self, sha: str | None) -> str | None: + if sha is None: + return None + if sha not in self.shas: + self.shas[sha] = f"{len(self.shas) + 1:040x}" + return self.shas[sha] + + +def _iso(value: datetime | None) -> str | None: + return None if value is None else value.isoformat() + + +def _paths(paths: tuple[str, ...]) -> list[str]: + return [ + f"docs/topics/f{index}.md" if base_branch_guard.in_docs_lane(path) else f"tools/f{index}.py" + for index, path in enumerate(paths) + ] + + +def anonymous(facts: pr_intake.Facts, anon: Anonymizer) -> dict: + if facts.bot_comment is not None and facts.bot_comment.data is not None: + raise SystemExit("a bot comment carries a data line, which this script does not yet remap") + n = anon.numbers + return { + "number": n[facts.number], + "state": facts.state, + "draft": facts.draft, + "author": anon.login(facts.author), + "author_is_bot": facts.author_is_bot, + "title": "t", + "body": TICKED if pr_intake.editorial_ticked(facts.body) else "b", + "base": facts.base, + "head_sha": anon.sha(facts.head_sha), + "created_at": _iso(facts.created_at), + "updated_at": _iso(facts.updated_at), + "labels": sorted(facts.labels), + "paths": _paths(facts.paths), + "paths_truncated": facts.paths_truncated, + "references": [ + { + "number": n[ref.number], "kind": ref.kind, "is_pull": ref.is_pull, "state": ref.state, + "labels": sorted(ref.labels), "assignees": sorted(anon.login(a) for a in ref.assignees), + "milestoned": ref.milestoned, "author_commented_at": _iso(ref.author_commented_at), + "unresolved": ref.unresolved, + } + for ref in facts.references + ], + "references_dropped": facts.references_dropped, + "reviews": [ + { + "login": anon.login(r.login), "state": r.state, "association": r.association, + "submitted_at": _iso(r.submitted_at), "commit_id": anon.sha(r.commit_id), + } + for r in facts.reviews + ], + "last_unlabel": None if facts.last_unlabel is None else [ + anon.login(facts.last_unlabel[0]), _iso(facts.last_unlabel[1]) + ], + "last_author_comment_at": _iso(facts.last_author_comment_at), + "bot_comment": None if facts.bot_comment is None else { + "id": 1, "body": "", "data": None, "legacy": facts.bot_comment.legacy + }, + "auto_merge": facts.auto_merge, + "author_record": facts.author_record, + "held_use": {str(n[k]): [n[v] for v in vs] for k, vs in sorted(facts.held_use.items())}, + "roster": sorted(anon.login(login) for login in facts.roster), + "mode": facts.mode, + "sweep": facts.sweep, + "event_action": facts.event_action, + } + + +def restore(row: dict) -> pr_intake.Facts: + """The anonymous row as Facts, read the way the population test reads it.""" + def when(value): + return None if value is None else datetime.fromisoformat(value) + + return pr_intake.Facts(**{ + **row, + "created_at": when(row["created_at"]), "updated_at": when(row["updated_at"]), + "last_author_comment_at": when(row["last_author_comment_at"]), + "labels": frozenset(row["labels"]), "roster": frozenset(row["roster"]), "paths": tuple(row["paths"]), + "references": tuple( + pr_intake.Reference(**{**r, "labels": frozenset(r["labels"]), "assignees": frozenset(r["assignees"]), + "author_commented_at": when(r["author_commented_at"])}) + for r in row["references"] + ), + "reviews": tuple(pr_intake.Review(**{**r, "submitted_at": when(r["submitted_at"])}) for r in row["reviews"]), + "last_unlabel": None if row["last_unlabel"] is None else (row["last_unlabel"][0], when(row["last_unlabel"][1])), + "held_use": {int(k): tuple(v) for k, v in row["held_use"].items()}, + "bot_comment": None if row["bot_comment"] is None else pr_intake.BotComment(**row["bot_comment"]), + }) + + +def outcome(decision: pr_intake.Decision) -> dict: + return {"outcome": decision.outcome, "route": decision.route, "reasons": list(decision.reasons)} + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--gh", default=gh_runner.GH) + args = parser.parse_args() + run = gh_runner.make_runner(args.gh) + today = date.today() + roster = roadmap_model.trusted_logins(ROOT) + listings = pr_intake.load_listings(run) + counter = pr_intake.CallCounter(limit=5_000) + collected = [ + pr_intake.collect_facts(run, int(pull["number"]), listings=listings, roster=roster, mode="enforced", + sweep=True, event_action=None, counter=counter) + for pull in sorted(listings.open_pulls, key=lambda pull: pull["number"]) + ] + numbers = [f.number for f in collected] + for f in collected: + numbers.extend(ref.number for ref in f.references) + for issue, pulls in f.held_use.items(): + numbers.append(issue) + numbers.extend(pulls) + anon = Anonymizer(roster, numbers) + rows = [] + for f in collected: + row = anonymous(f, anon) + real = outcome(pr_intake.decide(f, today)) + if outcome(pr_intake.decide(restore(row), today)) != real: + raise SystemExit(f"#{f.number}: the anonymous facts decide differently from the real ones") + rows.append({"facts": row, "today": today.isoformat(), "expected": real}) + print(f"#{f.number} -> {row['number']}: {real['outcome']} route={real['route']} reasons={real['reasons']}") + document = { + "generated_by": "tests/fixtures/pr_intake/record_population.py", + "recorded": today.isoformat(), + "rows": rows, + } + OUT.write_text(json.dumps(document, indent=1) + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/fixtures/pr_intake/reviews.json b/tests/fixtures/pr_intake/reviews.json new file mode 100644 index 00000000..654a6417 --- /dev/null +++ b/tests/fixtures/pr_intake/reviews.json @@ -0,0 +1,570 @@ +[ + { + "_links": { + "html": { + "href": "https://api.github.com/scrubbed" + }, + "pull_request": { + "href": "https://api.github.com/scrubbed" + } + }, + "author_association": "COLLABORATOR", + "body": "scrubbed", + "commit_id": "e523b2d9bf4cfc42b13899657e79b10fb1d7754d", + "copilot_code_review_effort": null, + "html_url": "https://api.github.com/scrubbed", + "id": 5433118084, + "is_copilot_code_review": false, + "node_id": "PRR_kwDOR_dibc8AAAABQ9bNhA", + "pull_request_url": "https://api.github.com/scrubbed", + "state": "CHANGES_REQUESTED", + "submitted_at": "2026-10-06T19:30:33Z", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-4", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "_links": { + "html": { + "href": "https://api.github.com/scrubbed" + }, + "pull_request": { + "href": "https://api.github.com/scrubbed" + } + }, + "author_association": "NONE", + "body": "scrubbed", + "commit_id": "51ffd7de75b8d1d7698a8dabdca7cf1f0a11c6dd", + "copilot_code_review_effort": null, + "html_url": "https://api.github.com/scrubbed", + "id": 5439566480, + "is_copilot_code_review": false, + "node_id": "PRR_kwDOR_dibc8AAAABRDkykA", + "pull_request_url": "https://api.github.com/scrubbed", + "state": "COMMENTED", + "submitted_at": "2026-10-07T08:22:47Z", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "_links": { + "html": { + "href": "https://api.github.com/scrubbed" + }, + "pull_request": { + "href": "https://api.github.com/scrubbed" + } + }, + "author_association": "NONE", + "body": "scrubbed", + "commit_id": "51ffd7de75b8d1d7698a8dabdca7cf1f0a11c6dd", + "copilot_code_review_effort": null, + "html_url": "https://api.github.com/scrubbed", + "id": 5439566767, + "is_copilot_code_review": false, + "node_id": "PRR_kwDOR_dibc8AAAABRDkzrw", + "pull_request_url": "https://api.github.com/scrubbed", + "state": "COMMENTED", + "submitted_at": "2026-10-07T08:22:49Z", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "_links": { + "html": { + "href": "https://api.github.com/scrubbed" + }, + "pull_request": { + "href": "https://api.github.com/scrubbed" + } + }, + "author_association": "NONE", + "body": "scrubbed", + "commit_id": "51ffd7de75b8d1d7698a8dabdca7cf1f0a11c6dd", + "copilot_code_review_effort": null, + "html_url": "https://api.github.com/scrubbed", + "id": 5439567050, + "is_copilot_code_review": false, + "node_id": "PRR_kwDOR_dibc8AAAABRDk0yg", + "pull_request_url": "https://api.github.com/scrubbed", + "state": "COMMENTED", + "submitted_at": "2026-10-07T08:22:50Z", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "_links": { + "html": { + "href": "https://api.github.com/scrubbed" + }, + "pull_request": { + "href": "https://api.github.com/scrubbed" + } + }, + "author_association": "NONE", + "body": "scrubbed", + "commit_id": "51ffd7de75b8d1d7698a8dabdca7cf1f0a11c6dd", + "copilot_code_review_effort": null, + "html_url": "https://api.github.com/scrubbed", + "id": 5439567326, + "is_copilot_code_review": false, + "node_id": "PRR_kwDOR_dibc8AAAABRDk13g", + "pull_request_url": "https://api.github.com/scrubbed", + "state": "COMMENTED", + "submitted_at": "2026-10-07T08:22:52Z", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "_links": { + "html": { + "href": "https://api.github.com/scrubbed" + }, + "pull_request": { + "href": "https://api.github.com/scrubbed" + } + }, + "author_association": "NONE", + "body": "scrubbed", + "commit_id": "51ffd7de75b8d1d7698a8dabdca7cf1f0a11c6dd", + "copilot_code_review_effort": null, + "html_url": "https://api.github.com/scrubbed", + "id": 5439567581, + "is_copilot_code_review": false, + "node_id": "PRR_kwDOR_dibc8AAAABRDk23Q", + "pull_request_url": "https://api.github.com/scrubbed", + "state": "COMMENTED", + "submitted_at": "2026-10-07T08:22:53Z", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "_links": { + "html": { + "href": "https://api.github.com/scrubbed" + }, + "pull_request": { + "href": "https://api.github.com/scrubbed" + } + }, + "author_association": "NONE", + "body": "scrubbed", + "commit_id": "51ffd7de75b8d1d7698a8dabdca7cf1f0a11c6dd", + "copilot_code_review_effort": null, + "html_url": "https://api.github.com/scrubbed", + "id": 5439567841, + "is_copilot_code_review": false, + "node_id": "PRR_kwDOR_dibc8AAAABRDk34Q", + "pull_request_url": "https://api.github.com/scrubbed", + "state": "COMMENTED", + "submitted_at": "2026-10-07T08:22:55Z", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "_links": { + "html": { + "href": "https://api.github.com/scrubbed" + }, + "pull_request": { + "href": "https://api.github.com/scrubbed" + } + }, + "author_association": "NONE", + "body": "scrubbed", + "commit_id": "51ffd7de75b8d1d7698a8dabdca7cf1f0a11c6dd", + "copilot_code_review_effort": null, + "html_url": "https://api.github.com/scrubbed", + "id": 5439568086, + "is_copilot_code_review": false, + "node_id": "PRR_kwDOR_dibc8AAAABRDk41g", + "pull_request_url": "https://api.github.com/scrubbed", + "state": "COMMENTED", + "submitted_at": "2026-10-07T08:22:56Z", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "_links": { + "html": { + "href": "https://api.github.com/scrubbed" + }, + "pull_request": { + "href": "https://api.github.com/scrubbed" + } + }, + "author_association": "NONE", + "body": "scrubbed", + "commit_id": "dd7e3b4ee1d7f66c6788175e918033be1dc020b7", + "copilot_code_review_effort": null, + "html_url": "https://api.github.com/scrubbed", + "id": 5455789242, + "is_copilot_code_review": false, + "node_id": "PRR_kwDOR_dibc8AAAABRTC8ug", + "pull_request_url": "https://api.github.com/scrubbed", + "state": "COMMENTED", + "submitted_at": "2026-10-08T11:23:04Z", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "_links": { + "html": { + "href": "https://api.github.com/scrubbed" + }, + "pull_request": { + "href": "https://api.github.com/scrubbed" + } + }, + "author_association": "MEMBER", + "body": "scrubbed", + "commit_id": "51d2de76613258c25591a72b7b1a77ef7b5b134c", + "copilot_code_review_effort": null, + "html_url": "https://api.github.com/scrubbed", + "id": 5505057701, + "is_copilot_code_review": false, + "node_id": "PRR_kwDOR_dibc8AAAABSCCDpQ", + "pull_request_url": "https://api.github.com/scrubbed", + "state": "CHANGES_REQUESTED", + "submitted_at": "2026-10-13T14:24:13Z", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "_links": { + "html": { + "href": "https://api.github.com/scrubbed" + }, + "pull_request": { + "href": "https://api.github.com/scrubbed" + } + }, + "author_association": "MEMBER", + "body": "scrubbed", + "commit_id": "9d3efa31a6ace6595a0e0f42f894a70af943da49", + "copilot_code_review_effort": null, + "html_url": "https://api.github.com/scrubbed", + "id": 5699286816, + "is_copilot_code_review": false, + "node_id": "PRR_kwDOR_dibc8AAAABU7Q3IA", + "pull_request_url": "https://api.github.com/scrubbed", + "state": "CHANGES_REQUESTED", + "submitted_at": "2026-10-29T14:21:37Z", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "_links": { + "html": { + "href": "https://api.github.com/scrubbed" + }, + "pull_request": { + "href": "https://api.github.com/scrubbed" + } + }, + "author_association": "NONE", + "body": "scrubbed", + "commit_id": "51ffd7de75b8d1d7698a8dabdca7cf1f0a11c6dd", + "copilot_code_review_effort": null, + "html_url": "https://api.github.com/scrubbed", + "id": 900001, + "is_copilot_code_review": false, + "node_id": "PRR_kwDOR_dibc8AAAABRDkykA", + "pull_request_url": "https://api.github.com/scrubbed", + "state": "APPROVED", + "submitted_at": "2026-10-07T08:22:47Z", + "user": null + }, + { + "_links": { + "html": { + "href": "https://api.github.com/scrubbed" + }, + "pull_request": { + "href": "https://api.github.com/scrubbed" + } + }, + "author_association": "NONE", + "body": "scrubbed", + "commit_id": "51ffd7de75b8d1d7698a8dabdca7cf1f0a11c6dd", + "copilot_code_review_effort": null, + "html_url": "https://api.github.com/scrubbed", + "id": 900002, + "is_copilot_code_review": false, + "node_id": "PRR_kwDOR_dibc8AAAABRDkykA", + "pull_request_url": "https://api.github.com/scrubbed", + "state": "APPROVED", + "submitted_at": "2026-10-07T08:22:47Z", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-bot[bot]", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Bot", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "_links": { + "html": { + "href": "https://api.github.com/scrubbed" + }, + "pull_request": { + "href": "https://api.github.com/scrubbed" + } + }, + "author_association": "MEMBER", + "body": "scrubbed", + "commit_id": "9d3efa31a6ace6595a0e0f42f894a70af943da49", + "copilot_code_review_effort": null, + "html_url": "https://api.github.com/scrubbed", + "id": 900003, + "is_copilot_code_review": false, + "node_id": "PRR_kwDOR_dibc8AAAABU7Q3IA", + "pull_request_url": "https://api.github.com/scrubbed", + "state": "DISMISSED", + "submitted_at": "2026-10-29T15:00:00Z", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + } +] diff --git a/tests/fixtures/pr_intake/scrub.py b/tests/fixtures/pr_intake/scrub.py new file mode 100644 index 00000000..71c06946 --- /dev/null +++ b/tests/fixtures/pr_intake/scrub.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Strip contributor text, logins, emails, and links from recorded GitHub API responses. + +Version 1.0. Owner: ACS project leads. Used to record the intake tool's fixtures. + +Usage: python3 -I scrub.py OUT_DIR RAW.json [RAW.json ...] + +Each response was recorded read-only with `gh api` against this repository. The fixtures +are committed, so nothing a contributor wrote, and nothing that names a contributor, may +survive. The scrub replaces `email`, `name`, `message`, `title`, `body`, `html_url`, +`avatar_url`, `url`, and the other free-text keys below with fixed strings at any depth. +Every `login` becomes `login-N`, numbered by first appearance across all the files of one +run, so the same person is the same fixed string in every fixture. The two bots the gate +names stay as they are, because they are not contributors and the tests need them. + +Structure survives: `type`, ids of non-person objects, states, label names, file paths, +SHAs, and timestamps. A label's `name` is kept because the gate reads label names. Any +string that is a URL becomes a fixed API URL, and any string that still contains a +recorded login is replaced whole. The run fails if an email or a non-API URL remains. +""" +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +KEPT_LOGINS = frozenset({"github-actions[bot]", "dependabot[bot]"}) +TEXT_KEYS = frozenset({ + "email", "name", "message", "title", "body", "html_url", "avatar_url", "url", + "patch", "description", "full_name", "payload", "signature", "body_text", "body_html", +}) +# Branch names are free text a contributor chooses. The two base branches are not. +KEPT_REFS = frozenset({"main", "integration"}) +TEXT = "scrubbed" +API_URL = "https://api.github.com/scrubbed" +_EMAIL = re.compile(r"@[A-Za-z0-9-]{1,63}\.[A-Za-z]{2,24}") +_URL = re.compile(r"https://(?!api\.github\.com/)") + + +class Scrubber: + def __init__(self) -> None: + self.logins: dict[str, str] = {} + + def login(self, value: str) -> str: + if value in KEPT_LOGINS: + return value + key = value.casefold() + if key not in self.logins: + self.logins[key] = f"login-{len(self.logins) + 1}" + return self.logins[key] + + def collect(self, node: object) -> None: + """Number every login first, so later strings can be checked against all of them.""" + if isinstance(node, dict): + for key, value in node.items(): + if key == "login" and isinstance(value, str): + self.login(value) + else: + self.collect(value) + elif isinstance(node, list): + for item in node: + self.collect(item) + + def string(self, value: str) -> str: + if value.startswith(("http://", "https://")): + return API_URL + folded = value.casefold() + for original in self.logins: + if original in folded and value not in KEPT_LOGINS: + return TEXT + return value + + def scrub(self, node: object, parent: str | None = None) -> object: + if isinstance(node, list): + return [self.scrub(item, parent) for item in node] + if not isinstance(node, dict): + return self.string(node) if isinstance(node, str) else node + person = "login" in node + out = {} + for key, value in node.items(): + if key == "login" and isinstance(value, str): + out[key] = self.login(value) + elif person and key == "id": + out[key] = 1 + elif person and key == "node_id": + out[key] = TEXT + elif key == "name" and parent in ("label", "labels"): + out[key] = value + elif key in TEXT_KEYS and isinstance(value, str): + out[key] = API_URL if key.endswith("url") else TEXT + elif key.endswith("_url") and isinstance(value, str): + out[key] = API_URL + elif key == "ref" and isinstance(value, str) and value not in KEPT_REFS: + out[key] = TEXT + elif key == "label" and isinstance(value, str): + out[key] = TEXT + else: + out[key] = self.scrub(value, key) + return out + + +def check(text: str, name: str) -> None: + if _EMAIL.search(text) or _URL.search(text) or "http://" in text: + raise SystemExit(f"{name}: an email or a non-API URL survived the scrub") + + +def main(argv: list[str]) -> int: + if len(argv) < 3: + raise SystemExit("usage: scrub.py OUT_DIR RAW.json [RAW.json ...]") + out_dir = Path(argv[1]) + raws = [(Path(path), json.loads(Path(path).read_text(encoding="utf-8"))) for path in argv[2:]] + scrubber = Scrubber() + for _path, data in raws: + scrubber.collect(data) + for path, data in raws: + text = json.dumps(scrubber.scrub(data), indent=1, sort_keys=True) + "\n" + check(text, path.name) + (out_dir / path.name).write_text(text, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tests/fixtures/pr_intake/timeline.json b/tests/fixtures/pr_intake/timeline.json new file mode 100644 index 00000000..4bc34dc1 --- /dev/null +++ b/tests/fixtures/pr_intake/timeline.json @@ -0,0 +1,697 @@ +[ + { + "actor": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "commit_id": "1f40023e4602a726dca9b3de9661d3c0f62cff67", + "commit_url": "https://api.github.com/scrubbed", + "created_at": "2026-10-06T09:13:45Z", + "event": "head_ref_force_pushed", + "id": 32584026958, + "node_id": "HRFPE_lADOR_dibc8AAAABVSqHm88AAAAHlijLTg", + "performed_via_github_app": null, + "url": "https://api.github.com/scrubbed" + }, + { + "actor": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-1", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "created_at": "2026-10-06T09:14:29Z", + "event": "cross-referenced", + "source": { + "issue": { + "active_lock_reason": null, + "assignee": null, + "assignees": [], + "author_association": "NONE", + "body": "scrubbed", + "closed_at": null, + "comments": 3, + "comments_url": "https://api.github.com/scrubbed", + "created_at": "2026-09-23T01:02:59Z", + "events_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 5547262827, + "issue_dependencies_summary": { + "blocked_by": 0, + "blocking": 0, + "total_blocked_by": 0, + "total_blocking": 0 + }, + "issue_field_values": [], + "labels": [ + { + "archived_at": null, + "archived_by": null, + "color": "5319e7", + "default": false, + "description": "scrubbed", + "id": 12125610305, + "name": "type:conformance", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hQQ", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "0e8a16", + "default": false, + "description": "scrubbed", + "id": 12125610333, + "name": "scope:in-focus", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hXQ", + "url": "https://api.github.com/scrubbed" + }, + { + "archived_at": null, + "archived_by": null, + "color": "0e8a16", + "default": false, + "description": "scrubbed", + "id": 12125610443, + "name": "status:accepted", + "node_id": "LA_kwDOR_dibc8AAAAC0r4hyw", + "url": "https://api.github.com/scrubbed" + } + ], + "labels_url": "https://api.github.com/scrubbed", + "locked": false, + "milestone": { + "closed_at": null, + "closed_issues": 1, + "created_at": "2026-10-05T08:09:21Z", + "creator": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "description": "scrubbed", + "due_on": "2026-12-31T00:00:00Z", + "html_url": "https://api.github.com/scrubbed", + "id": 18318998, + "labels_url": "https://api.github.com/scrubbed", + "node_id": "MI_kwDOR_dibc4BF4aW", + "number": 9, + "open_issues": 24, + "state": "open", + "title": "scrubbed", + "updated_at": "2026-10-29T14:45:09Z", + "url": "https://api.github.com/scrubbed" + }, + "node_id": "I_kwDOR_dibc8AAAABSqSDaw", + "number": 184, + "performed_via_github_app": null, + "pinned_comment": null, + "reactions": { + "+1": 1, + "-1": 0, + "confused": 0, + "eyes": 1, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 2, + "url": "https://api.github.com/scrubbed" + }, + "repository": { + "allow_forking": true, + "archive_url": "https://api.github.com/scrubbed", + "archived": false, + "assignees_url": "https://api.github.com/scrubbed", + "blobs_url": "https://api.github.com/scrubbed", + "branches_url": "https://api.github.com/scrubbed", + "clone_url": "https://api.github.com/scrubbed", + "collaborators_url": "https://api.github.com/scrubbed", + "comments_url": "https://api.github.com/scrubbed", + "commits_url": "https://api.github.com/scrubbed", + "compare_url": "https://api.github.com/scrubbed", + "contents_url": "https://api.github.com/scrubbed", + "contributors_url": "https://api.github.com/scrubbed", + "created_at": "2026-04-10T22:30:37Z", + "default_branch": "integration", + "deployments_url": "https://api.github.com/scrubbed", + "description": "scrubbed", + "disabled": false, + "downloads_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "fork": false, + "forks": 78, + "forks_count": 78, + "forks_url": "https://api.github.com/scrubbed", + "full_name": "scrubbed", + "git_commits_url": "https://api.github.com/scrubbed", + "git_refs_url": "https://api.github.com/scrubbed", + "git_tags_url": "https://api.github.com/scrubbed", + "git_url": "https://api.github.com/scrubbed", + "has_discussions": true, + "has_downloads": false, + "has_issues": true, + "has_pages": true, + "has_projects": true, + "has_pull_requests": true, + "has_wiki": false, + "homepage": "https://api.github.com/scrubbed", + "hooks_url": "https://api.github.com/scrubbed", + "html_url": "https://api.github.com/scrubbed", + "id": 1207394925, + "is_template": false, + "issue_comment_url": "https://api.github.com/scrubbed", + "issue_events_url": "https://api.github.com/scrubbed", + "issues_url": "https://api.github.com/scrubbed", + "keys_url": "https://api.github.com/scrubbed", + "labels_url": "https://api.github.com/scrubbed", + "language": "TypeScript", + "languages_url": "https://api.github.com/scrubbed", + "license": { + "key": "apache-2.0", + "name": "scrubbed", + "node_id": "MDc6TGljZW5zZTI=", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/scrubbed" + }, + "merges_url": "https://api.github.com/scrubbed", + "milestones_url": "https://api.github.com/scrubbed", + "mirror_url": null, + "name": "scrubbed", + "node_id": "R_kgDOR_dibQ", + "notifications_url": "https://api.github.com/scrubbed", + "open_issues": 204, + "open_issues_count": 204, + "owner": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-12", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Organization", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "permissions": { + "admin": true, + "maintain": true, + "pull": true, + "push": true, + "triage": true + }, + "private": false, + "pull_request_creation_policy": "all", + "pulls_url": "https://api.github.com/scrubbed", + "pushed_at": "2026-10-29T14:49:44Z", + "releases_url": "https://api.github.com/scrubbed", + "size": 17260, + "ssh_url": "https://api.github.com/scrubbed", + "stargazers_count": 228, + "stargazers_url": "https://api.github.com/scrubbed", + "statuses_url": "https://api.github.com/scrubbed", + "subscribers_url": "https://api.github.com/scrubbed", + "subscription_url": "https://api.github.com/scrubbed", + "svn_url": "https://api.github.com/scrubbed", + "tags_url": "https://api.github.com/scrubbed", + "teams_url": "https://api.github.com/scrubbed", + "topics": [ + "agent-safety", + "ai-agents", + "governance", + "middleware", + "observability", + "open-standard", + "opentelemetry", + "owasp", + "policy-enforcement", + "runtime-control" + ], + "trees_url": "https://api.github.com/scrubbed", + "updated_at": "2026-10-29T16:21:05Z", + "url": "https://api.github.com/scrubbed", + "visibility": "public", + "watchers": 228, + "watchers_count": 228, + "web_commit_signoff_required": true + }, + "repository_url": "https://api.github.com/scrubbed", + "state": "open", + "state_reason": null, + "sub_issues_summary": { + "completed": 0, + "percent_completed": 0, + "total": 0 + }, + "timeline_url": "https://api.github.com/scrubbed", + "title": "scrubbed", + "type": null, + "updated_at": "2026-10-24T15:53:50Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-15", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + "type": "issue" + }, + "updated_at": "2026-10-06T09:14:29Z" + }, + { + "_links": { + "html": { + "href": "https://api.github.com/scrubbed" + }, + "pull_request": { + "href": "https://api.github.com/scrubbed" + } + }, + "author_association": "COLLABORATOR", + "body": "scrubbed", + "commit_id": "e523b2d9bf4cfc42b13899657e79b10fb1d7754d", + "event": "reviewed", + "html_url": "https://api.github.com/scrubbed", + "id": 5433118084, + "node_id": "PRR_kwDOR_dibc8AAAABQ9bNhA", + "pull_request_url": "https://api.github.com/scrubbed", + "state": "changes_requested", + "submitted_at": "2026-10-06T19:30:33Z", + "updated_at": "2026-10-06T19:30:33Z", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-4", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "actor": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-15", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "author_association": "NONE", + "body": "scrubbed", + "created_at": "2026-10-07T10:25:35Z", + "event": "commented", + "html_url": "https://api.github.com/scrubbed", + "id": 6035978630, + "issue_url": "https://api.github.com/scrubbed", + "minimized": null, + "node_id": "IC_kwDOR_dibc8AAAABZ8W5hg", + "performed_via_github_app": null, + "reactions": { + "+1": 0, + "-1": 0, + "confused": 0, + "eyes": 0, + "heart": 0, + "hooray": 0, + "laugh": 0, + "rocket": 0, + "total_count": 0, + "url": "https://api.github.com/scrubbed" + }, + "updated_at": "2026-10-07T10:25:35Z", + "url": "https://api.github.com/scrubbed", + "user": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-15", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "User", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + } + }, + { + "author": { + "date": "2027-01-01T00:00:00Z", + "email": "scrubbed", + "name": "scrubbed" + }, + "committer": { + "date": "2027-01-01T00:00:00Z", + "email": "scrubbed", + "name": "scrubbed" + }, + "event": "committed", + "html_url": "https://api.github.com/scrubbed", + "message": "scrubbed", + "node_id": "C_kwDOR_dibdoAKGI3ZjAyZTE4NmQ4MzUwN2E1M2VmYzliODgwMWY4NGFkNzQzNTRkMmU", + "parents": [ + { + "html_url": "https://api.github.com/scrubbed", + "sha": "8a459524530b320c70c68d1d0b85bbcabb3771e6", + "url": "https://api.github.com/scrubbed" + } + ], + "sha": "b7f02e186d83507a53efc9b8801f84ad74354d2e", + "tree": { + "sha": "9777a40e072df0c4f35af60c36e69640af2ba8bf", + "url": "https://api.github.com/scrubbed" + }, + "url": "https://api.github.com/scrubbed", + "verification": { + "payload": null, + "reason": "unsigned", + "signature": null, + "verified": false, + "verified_at": null + } + }, + { + "author": { + "date": "2027-01-01T00:00:00Z", + "email": "scrubbed", + "name": "scrubbed" + }, + "committer": { + "date": "2027-01-01T00:00:00Z", + "email": "scrubbed", + "name": "scrubbed" + }, + "event": "committed", + "html_url": "https://api.github.com/scrubbed", + "message": "scrubbed", + "node_id": "C_kwDOR_dibdoAKGEwODU0MmNkZmFhMjc1MmE4MzhmZDg5OTY1ODcxMDliMjQxOTljZTk", + "parents": [ + { + "html_url": "https://api.github.com/scrubbed", + "sha": "b7f02e186d83507a53efc9b8801f84ad74354d2e", + "url": "https://api.github.com/scrubbed" + } + ], + "sha": "a08542cdfaa2752a838fd8996587109b24199ce9", + "tree": { + "sha": "0b5d518d397c5cd664370fef552037c7509b88b0", + "url": "https://api.github.com/scrubbed" + }, + "url": "https://api.github.com/scrubbed", + "verification": { + "payload": null, + "reason": "unsigned", + "signature": null, + "verified": false, + "verified_at": null + } + }, + { + "author": { + "date": "2027-01-01T00:00:00Z", + "email": "scrubbed", + "name": "scrubbed" + }, + "committer": { + "date": "2027-01-01T00:00:00Z", + "email": "scrubbed", + "name": "scrubbed" + }, + "event": "committed", + "html_url": "https://api.github.com/scrubbed", + "message": "scrubbed", + "node_id": "C_kwDOR_dibdoAKDJiNWYwODE0NDljYzZhOGE5ZTM1MWEyOWY0ZmM0YmU5OWU0NzhiNTM", + "parents": [ + { + "html_url": "https://api.github.com/scrubbed", + "sha": "a08542cdfaa2752a838fd8996587109b24199ce9", + "url": "https://api.github.com/scrubbed" + } + ], + "sha": "2b5f081449cc6a8a9e351a29f4fc4be99e478b53", + "tree": { + "sha": "76d4265152a36856e17a6e189dc9d9e5da563d15", + "url": "https://api.github.com/scrubbed" + }, + "url": "https://api.github.com/scrubbed", + "verification": { + "payload": null, + "reason": "unsigned", + "signature": null, + "verified": false, + "verified_at": null + } + }, + { + "actor": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "github-actions[bot]", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Bot", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "commit_id": null, + "commit_url": null, + "created_at": "2026-10-29T07:57:26Z", + "event": "labeled", + "id": 34560739375, + "intent": null, + "label": { + "color": "ededed", + "name": "status:needs-triage" + }, + "node_id": "LE_lADOR_dibc8AAAABa1DZj88AAAAIC_sILw", + "performed_via_github_app": null, + "url": "https://api.github.com/scrubbed" + }, + { + "actor": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "github-actions[bot]", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Bot", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "commit_id": null, + "commit_url": null, + "created_at": "2026-10-20T00:00:00Z", + "event": "unlabeled", + "id": 920001, + "intent": null, + "label": { + "color": "ededed", + "name": "status:needs-triage" + }, + "node_id": "LE_lADOR_dibc8AAAABa1DZj88AAAAIC_sILw", + "performed_via_github_app": null, + "url": "https://api.github.com/scrubbed" + }, + { + "actor": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Bot", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "commit_id": null, + "commit_url": null, + "created_at": "2026-10-25T00:00:00Z", + "event": "unlabeled", + "id": 920002, + "intent": null, + "label": { + "color": "ededed", + "name": "status:needs-triage" + }, + "node_id": "LE_lADOR_dibc8AAAABa1DZj88AAAAIC_sILw", + "performed_via_github_app": null, + "url": "https://api.github.com/scrubbed" + }, + { + "actor": { + "avatar_url": "https://api.github.com/scrubbed", + "events_url": "https://api.github.com/scrubbed", + "followers_url": "https://api.github.com/scrubbed", + "following_url": "https://api.github.com/scrubbed", + "gists_url": "https://api.github.com/scrubbed", + "gravatar_id": "", + "html_url": "https://api.github.com/scrubbed", + "id": 1, + "login": "login-13", + "node_id": "scrubbed", + "organizations_url": "https://api.github.com/scrubbed", + "received_events_url": "https://api.github.com/scrubbed", + "repos_url": "https://api.github.com/scrubbed", + "site_admin": false, + "starred_url": "https://api.github.com/scrubbed", + "subscriptions_url": "https://api.github.com/scrubbed", + "type": "Bot", + "url": "https://api.github.com/scrubbed", + "user_view_type": "public" + }, + "commit_id": null, + "commit_url": null, + "created_at": "2026-10-28T00:00:00Z", + "event": "unlabeled", + "id": 920003, + "intent": null, + "label": { + "color": "ededed", + "name": "type:refimpl" + }, + "node_id": "LE_lADOR_dibc8AAAABa1DZj88AAAAIC_sILw", + "performed_via_github_app": null, + "url": "https://api.github.com/scrubbed" + } +] diff --git a/tests/test_pr_intake.py b/tests/test_pr_intake.py index 8a2240b9..e9dd7124 100644 --- a/tests/test_pr_intake.py +++ b/tests/test_pr_intake.py @@ -706,11 +706,11 @@ def when(value): return facts(**raw) -@pytest.mark.skipif(not POPULATION.exists(), reason="recorded by Task 4") def test_decide_matches_the_recorded_population(): """Every recorded open pull request decides as recorded. - The fixture is a JSON list. Each row is an object: + The fixture is a JSON object: `{"generated_by": , + "recorded": "YYYY-MM-DD", "rows": [, ...]}`. Each row is an object: `{"facts": {: , ...}, "today": "YYYY-MM-DD", "expected": {"outcome": "pass"|"queue"|"skip", "route": , "reasons": [, ...]}}`. `facts` holds overrides on this module's `facts()` defaults in JSON form: datetimes as @@ -719,10 +719,339 @@ def test_decide_matches_the_recorded_population(): with string keys, and `bot_comment` as an object with the `BotComment` fields. `today` is optional and defaults to TODAY. """ - rows = json.loads(POPULATION.read_text()) - assert rows + document = json.loads(POPULATION.read_text()) + assert (Path(__file__).resolve().parents[1] / document["generated_by"]).exists() + rows = document["rows"] + assert len(rows) == 56 for row in rows: today = date.fromisoformat(row.get("today", TODAY.isoformat())) decision = decide(_from_json(row["facts"]), today) expected = row["expected"] assert outcome(decision) == (expected["outcome"], expected["route"], tuple(expected["reasons"])), row["facts"].get("number") + + +# --- Collecting facts ------------------------------------------------------------------- + +import copy # noqa: E402 +import re # noqa: E402 +import time # noqa: E402 + +import gh_runner # noqa: E402 +from pr_intake import BudgetStop, CallCounter, ListingError, Listings, collect_facts, load_listings # noqa: E402 + +FIXTURES = Path(__file__).resolve().parent / "fixtures" / "pr_intake" +API = f"repos/{roadmap_model.REPO}" +COLLECT_ROSTER = frozenset({"login-13", "login-4"}) +OWN_ISSUE_URL = f"https://github.com/{roadmap_model.REPO}/issues" + + +def fixture(name: str): + return json.loads((FIXTURES / name).read_text(encoding="utf-8")) + + +def http(obj, status: int = 200) -> str: + return f"HTTP/2.0 {status} OK\r\nContent-Type: application/json\r\n\r\n" + json.dumps(obj) + + +def slurp(*pages) -> str: + return json.dumps(list(pages)) + + +def runner_from_fixtures(mapping: dict): + """A runner that serves `mapping[path]` and counts every call. + + A value is the stdout string, or a `(code, stdout, stderr)` tuple. Every argv must + pass the real allowlist, so a test cannot pass on a shape the runner would refuse. + A path the mapping does not name fails the test. + """ + calls: list[list[str]] = [] + + def run(args, stdin=None): + gh_runner._check_allowlist(args) + calls.append(list(args)) + path = args[-1] + assert path in mapping, f"unexpected read {path}" + value = mapping[path] + return value if isinstance(value, tuple) else (0, value, "") + + run.calls = calls + return run + + +def listing_mapping(*, accepted=None, open_pulls=None, closed_pulls=None) -> dict: + accepted = fixture("accepted_all.json") if accepted is None else accepted + return { + f"{API}/issues?labels=status%3Aaccepted&state=all&per_page=100": slurp(accepted), + f"{API}/issues?milestone=%2A&state=all&per_page=100": slurp([i for i in accepted if i["milestone"]]), + f"{API}/pulls?state=open&per_page=100": slurp(fixture("pulls_open.json") if open_pulls is None else open_pulls), + f"{API}/pulls?state=closed&sort=updated&direction=desc&per_page=100": slurp( + fixture("pulls_closed.json") if closed_pulls is None else closed_pulls + ), + } + + +def pull_mapping(number: int = 229, *, pull=None, files=None, reviews=None, comments=None, timeline=None) -> dict: + pull = fixture("pull.json") if pull is None else pull + files = [fixture("files_page1.json"), fixture("files_page2.json")] if files is None else files + return { + f"{API}/pulls/{number}": http(pull), + f"{API}/pulls/{number}/files?per_page=100": slurp(*files), + f"{API}/pulls/{number}/reviews?per_page=100": slurp(fixture("reviews.json") if reviews is None else reviews), + f"{API}/issues/{number}/comments?per_page=100": slurp(fixture("comments.json") if comments is None else comments), + f"{API}/issues/{number}/timeline?per_page=100": slurp(fixture("timeline.json") if timeline is None else timeline), + } + + +def claim_mapping(*numbers: int, served: dict | None = None) -> dict: + served = served or {} + return {f"{API}/issues/{n}/comments?per_page=100": slurp(served.get(n, [])) for n in numbers} + + +def with_body(body: str, **changes) -> dict: + pull = fixture("pull.json") + pull["body"] = body + pull.update(changes) + return pull + + +def collect(mapping: dict, *, listings: Listings | None = None, number: int = 229, counter=None, roster=COLLECT_ROSTER): + run = runner_from_fixtures(mapping) + if listings is None: + listings = load_listings(runner_from_fixtures(listing_mapping())) + got = collect_facts( + run, number, listings=listings, roster=roster, mode="enforced", sweep=False, event_action="opened", + counter=counter or CallCounter(), + ) + return got, run.calls + + +def read_paths(calls) -> list[str]: + return [call[-1] for call in calls] + + +def test_collect_paginates_files_and_flags_truncation(): + got, calls = collect(pull_mapping()) + assert len(got.paths) == 56 and got.paths_truncated is False + assert ["api", "--paginate", "--slurp", f"{API}/pulls/229/files?per_page=100"] in calls + assert got.paths[0] == fixture("files_page1.json")[0]["filename"] + assert got.paths[-1] == fixture("files_page2.json")[-1]["filename"] + + short, _ = collect(pull_mapping(pull=with_body("scrubbed", changed_files=57))) + assert short.paths_truncated is True + + page = [{"filename": f"docs/topics/f{i}.md"} for i in range(100)] + full, _ = collect(pull_mapping(pull=with_body("scrubbed", changed_files=3000), files=[page] * 30)) + assert len(full.paths) == 3000 and full.paths_truncated is True + + +def test_changelog_anchor_is_not_a_reference(): + body = ( + "Bumps the thing.\n
Changelog\n" + '
\nRefs #351' + ) + got, calls = collect({**pull_mapping(pull=with_body(body)), **claim_mapping(351, 362)}) + numbers = [ref.number for ref in got.references] + assert 92 not in numbers and 361 not in numbers + assert set(numbers) == {351, 362} + + +def test_sixty_four_kilobyte_anchor_body_parses_under_one_second(): + bodies = ( + ("#92 ' * 2_000)[: closing_choice.MAX_CHARS], + ("#5 ") * 30, + (" list: + return [comment for comment in fixture("comments.json") if comment["id"] not in ids] + + +def test_bot_comment_found_by_exact_login_and_marker_only(): + got, _ = collect(pull_mapping()) + assert got.bot_comment.id == 910003 and got.bot_comment.legacy is False + assert got.bot_comment.data == {"reason": "no_accepted", "since": "2026-11-02", "head": "9d3efa31a6ac", "facts": "0123456789ab"} + + comments = fixture("comments.json") + for comment in comments: + if comment["id"] == 910003: + comment["user"]["login"] = "GitHub-Actions[bot]" + near_miss, _ = collect(pull_mapping(comments=comments)) + assert near_miss.bot_comment.id != 910003 and near_miss.bot_comment.legacy is True + + +def test_legacy_prefix_in_a_contributor_comment_is_not_adopted(): + bot_ids = [c["id"] for c in fixture("comments.json") if c["user"]["login"] == roadmap_model.BOT_LOGIN] + got, _ = collect(pull_mapping(comments=_comments_without(*bot_ids))) + assert got.bot_comment is None + + +def test_legacy_bot_comment_is_adopted(): + legacy_ids = [ + c["id"] for c in fixture("comments.json") + if c["user"]["login"] == roadmap_model.BOT_LOGIN and c["body"].startswith(pr_intake.LEGACY_PREFIX) + ] + got, _ = collect(pull_mapping(comments=_comments_without(910003))) + assert got.bot_comment.id == legacy_ids[0] and got.bot_comment.legacy is True and got.bot_comment.data is None + + keyword_only, _ = collect(pull_mapping(comments=_comments_without(910003, *legacy_ids))) + assert keyword_only.bot_comment.legacy is True + assert pr_intake.LEGACY_KEYWORD_MARKER in keyword_only.bot_comment.body + + +def test_listing_failure_raises_ListingError(): + for path in listing_mapping(): + mapping = listing_mapping() + mapping[path] = (1, "", "boom") + with pytest.raises(ListingError): + load_listings(runner_from_fixtures(mapping)) + + +def test_author_record_reads_reviews_once_per_merged_pull(): + closed = fixture("pulls_closed.json") + mine = [] + for pull_obj in closed: + if pull_obj["number"] in (357, 356): + pull_obj["user"]["login"] = "login-1" + mine.append(pull_obj["number"]) + approved = {"user": {"login": "Login-13", "type": "User"}, "state": "APPROVED", "author_association": "MEMBER", + "submitted_at": "2026-10-29T14:00:00Z", "commit_id": "c" * 40} + reviews = {357: [approved], 356: [{**approved, "author_association": "NONE"}]} + listings = load_listings(runner_from_fixtures(listing_mapping(closed_pulls=closed))) + mapping = { + **pull_mapping(), **pull_mapping(24), + **{f"{API}/pulls/{n}/reviews?per_page=100": slurp(reviews[n]) for n in mine}, + } + run = runner_from_fixtures(mapping) + for number in (229, 24): + got = collect_facts( + run, number, listings=listings, roster=COLLECT_ROSTER, mode="enforced", sweep=True, + event_action=None, counter=CallCounter(), + ) + assert got.author_record is True + record_reads = [path for path in read_paths(run.calls) if re.search(r"/pulls/35[67]/reviews", path)] + assert sorted(record_reads) == sorted(f"{API}/pulls/{n}/reviews?per_page=100" for n in mine) + assert listings.approved_merged["login-1"] == frozenset({357}) + + +def test_logins_are_casefolded_on_collection(): + pull = with_body("scrubbed") + pull["user"]["login"] = "Login-1" + reviews = fixture("reviews.json") + for review_obj in reviews: + if review_obj["user"] and review_obj["user"]["login"] == "login-13": + review_obj["user"]["login"] = "LOGIN-13" + timeline = fixture("timeline.json") + for event in timeline: + if event["event"] == "unlabeled" and event["actor"]["login"] == "login-13": + event["actor"]["login"] = "Login-13" + got, _ = collect(pull_mapping(pull=pull, reviews=reviews, timeline=timeline)) + assert got.author == "login-1" + assert "login-13" in {review.login for review in got.reviews} + assert got.last_unlabel[0] == "login-13" + assert got.last_author_comment_at is not None + + +def test_fixtures_carry_no_email_or_url(): + email = re.compile(r"@[A-Za-z0-9-]{1,63}\.[A-Za-z]{2,24}") + foreign = re.compile(r"https://(?!api\.github\.com/)") + files = sorted(FIXTURES.glob("*.json")) + assert len(files) >= 11 + for path in files: + text = path.read_text(encoding="utf-8") + assert not email.search(text), path.name + assert not foreign.search(text), path.name + assert "http://" not in text, path.name diff --git a/tools/pr_intake.py b/tools/pr_intake.py index e7cd17a1..23cd0fea 100644 --- a/tools/pr_intake.py +++ b/tools/pr_intake.py @@ -2,8 +2,9 @@ """Decide whether a pull request has an accepted issue behind it, and say so once. Version 1.0. Owner: ACS project leads. This module holds the data model, the parsing -helpers, and the gate: `decide` is a pure function of `Facts` and the date, with no I/O. -The collectors and the writers join it in later tasks of the same plan. +helpers, the gate, and the collector. `decide` is a pure function of `Facts` and the date, +with no I/O. `collect_facts` does every read, through `gh_runner`'s allowlisted shapes, +inside a call budget. The writers join them in a later task of the same plan. The workflow runs this under `python3 -I -S` from a checkout of `main`, never the pull request's head, with the standard library and the sibling modules under `tools/` only. @@ -17,13 +18,15 @@ from __future__ import annotations import hashlib +import json import re -from dataclasses import dataclass, replace +from dataclasses import dataclass, field, replace from datetime import date, datetime, timedelta, timezone import base_branch_guard import closing_choice import roadmap_model +from gh_runner import Runner, RunnerError, paginate, split_http # The marker sits on line 2 of the bot comment. The data line sits on line 1, ahead of it. MARKER = "" @@ -781,3 +784,392 @@ def render_comment(facts: Facts, decision: Decision, today: date) -> str: paragraphs.append(_keyword_warning(closes)) text = render_data_line(data) + "\n" + MARKER + "\n\n" + "\n\n".join(paragraphs) return text[:MAX_COMMENT] + + +# --- Collecting facts ------------------------------------------------------------------- + +_API = f"repos/{roadmap_model.REPO}" +# GitHub caps a pull request's files listing at 3,000 entries. +MAX_FILES = 3_000 +_OWN_LINK = f"https://github.com/{roadmap_model.REPO}".casefold() +# Bounded and linear. An opening tag scan stops at the next `<` or `>`, and the closing +# tag allows a bounded run of spaces. Neither crosses a line any differently, since no +# pattern here needs DOTALL. +_ANCHOR_OPEN = re.compile(r"]{0,2048}>", re.IGNORECASE) +_ANCHOR_CLOSE = re.compile(r"", re.IGNORECASE) +_HREF = re.compile(r"""(?]{0,2048})""", re.IGNORECASE) +_KIND_RANK = {"none": 0, "contribute": 1, "close": 2} + + +class ListingError(RuntimeError): + """A shared listing failed to arrive, so no pull request can be evaluated this run.""" + + +class BudgetStop(RuntimeError): + """The run spent its call budget. The caller stops and counts the rest as skipped.""" + + +class CallCounter: + """Counts runner calls across a run and stops the run past its limit. + + The check runs before the call, so the call that would exceed the limit never starts. + """ + + def __init__(self, limit: int = MAX_CALLS) -> None: + self.limit = limit + self.calls = 0 + + def wrap(self, run: Runner) -> Runner: + def counted(args: list[str], stdin: str | None = None) -> tuple[int, str, str]: + if self.calls >= self.limit: + raise BudgetStop(f"call budget of {self.limit} spent") + self.calls += 1 + return run(args, stdin) + + return counted + + +def _login(user: object) -> str | None: + """A casefolded login, or None for a deleted account or a malformed user.""" + if not isinstance(user, dict) or not isinstance(user.get("login"), str): + return None + return user["login"].casefold() + + +def _when(value: object) -> datetime | None: + if not isinstance(value, str): + return None + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def _label_names(labels: object) -> frozenset[str]: + return frozenset(label["name"] for label in labels or () if isinstance(label, dict) and isinstance(label.get("name"), str)) + + +@dataclass +class Listings: + """The shared listings one run reads once and every pull request's facts resolve against. + + `approved_merged` maps a casefolded author to the merged pull requests of theirs that a + lead approved. It fills lazily, one author at a time, and is cached for the run. + """ + + accepted: dict[int, dict] + milestoned: dict[int, dict] + open_pulls: list[dict] + merged_pulls: list[dict] + approved_merged: dict[str, frozenset[int]] = field(default_factory=dict) + + def approved_for(self, run: Runner, author: str, roster: frozenset[str]) -> frozenset[int]: + """The author's merged pull requests with an approval by a lead, read once per author.""" + if author not in self.approved_merged: + approved: set[int] = set() + for pull in self.merged_pulls: + if _login(pull.get("user")) != author: + continue + for review in paginate(run, f"{_API}/pulls/{int(pull['number'])}/reviews?per_page=100"): + user = review.get("user") if isinstance(review, dict) else None + if ( + isinstance(user, dict) and user.get("type") != "Bot" and _login(user) in roster + and review.get("state") == "APPROVED" and review.get("author_association") in LEAD_ASSOCIATIONS + ): + approved.add(int(pull["number"])) + break + # Cached only once every read succeeded, so a failed read is retried, never remembered. + self.approved_merged[author] = frozenset(approved) + return self.approved_merged[author] + + +def _issues_by_number(items: list) -> dict[int, dict]: + # The issues endpoint lists pull requests too. They are never issues here. + return { + int(item["number"]): item for item in items + if isinstance(item, dict) and "pull_request" not in item and isinstance(item.get("number"), int) + } + + +def _pulls(items: list) -> list[dict]: + return [item for item in items if isinstance(item, dict) and isinstance(item.get("number"), int)] + + +def load_listings(run: Runner) -> Listings: + """Read the four shared listings. Any failure is a ListingError, never an empty listing.""" + try: + accepted = paginate(run, f"{_API}/issues?labels=status%3Aaccepted&state=all&per_page=100") + milestoned = paginate(run, f"{_API}/issues?milestone=%2A&state=all&per_page=100") + open_pulls = paginate(run, f"{_API}/pulls?state=open&per_page=100") + closed_pulls = paginate(run, f"{_API}/pulls?state=closed&sort=updated&direction=desc&per_page=100") + except RunnerError as exc: + raise ListingError(f"a shared listing failed: {exc}") from exc + return Listings( + accepted=_issues_by_number(accepted), + milestoned=_issues_by_number(milestoned), + open_pulls=_pulls(open_pulls), + merged_pulls=[pull for pull in _pulls(closed_pulls) if pull.get("merged_at") is not None], + ) + + +def _ours(href: str) -> bool: + folded = href.casefold() + return folded == _OWN_LINK or folded.startswith(_OWN_LINK + "/") + + +def strip_foreign_anchors(text: str) -> str: + """Drop every HTML anchor that links outside this repository, with its text. + + A Dependabot changelog links other projects' issues as `#92`, and + the `#92` would otherwise read as this repository's issue. An anchor with no closing + tag drops the rest of the text, so nothing inside a foreign anchor is ever adopted. + """ + out: list[str] = [] + pos = 0 + while True: + opening = _ANCHOR_OPEN.search(text, pos) + if opening is None: + out.append(text[pos:]) + break + href = _HREF.search(opening.group(0)) + if href is not None and _ours(href.group(1)): + out.append(text[pos:opening.end()]) + pos = opening.end() + continue + out.append(text[pos:opening.start()]) + closing = _ANCHOR_CLOSE.search(text, opening.end()) + if closing is None: + break + out.append(" ") + pos = closing.end() + return "".join(out) + + +def referenced(title: str | None, body: str | None) -> tuple[list[tuple[int, str]], int]: + """The distinct numbers a pull request names, title first, and how many were dropped. + + A number named twice keeps its first position and its strongest spelling, so a + `Refs #5` before a `Closes #5` still reads as a close, which is what merging does. + """ + kinds: dict[int, str] = {} + for text in (title or "", body or ""): + cleaned = strip_foreign_anchors(text[: closing_choice.MAX_CHARS]) + for ref in closing_choice.parse_references(closing_choice.normalize(cleaned)): + previous = kinds.get(ref.number) + if previous is None or _KIND_RANK[ref.kind] > _KIND_RANK[previous]: + kinds[ref.number] = ref.kind + numbers = list(kinds) + return [(number, kinds[number]) for number in numbers[:MAX_REFERENCES]], max(0, len(numbers) - MAX_REFERENCES) + + +def _read_pull(run: Runner, number: int) -> dict: + code, stdout, _stderr = run(["api", "-i", f"{_API}/pulls/{number}"], None) + if not stdout: + raise RunnerError(f"pull request read exited {code}") + status, _headers, body = split_http(stdout) + if status != 200: + raise RunnerError(f"pull request read returned HTTP {status}") + try: + pull = json.loads(body) + except ValueError: + raise RunnerError("pull request read returned non-JSON output") from None + if not isinstance(pull, dict): + raise RunnerError("pull request read returned something other than an object") + return pull + + +def _resolve(number: int, kind: str, listings: Listings, pulls: dict[int, dict]) -> Reference: + """A reference resolved from the listings alone. Nothing here reads `issues/{m}`.""" + if number in pulls: + pull = pulls[number] + return Reference(number, kind, True, pull.get("state"), _label_names(pull.get("labels")), frozenset(), False, + None, False) + issue = listings.accepted.get(number) or listings.milestoned.get(number) + if issue is None: + return Reference(number, kind, False, None, frozenset(), frozenset(), False, None, True) + assignees = frozenset(login for login in map(_login, issue.get("assignees") or ()) if login) + return Reference( + number, kind, False, issue.get("state"), _label_names(issue.get("labels")), assignees, + issue.get("milestone") is not None, None, False, + ) + + +def _open_accepted(ref: Reference) -> bool: + return ( + not ref.is_pull and not ref.unresolved and (ref.state or "").casefold() == "open" + and roadmap_model.ACCEPTED in ref.labels + ) + + +def _earliest_by(comments: list, author: str) -> datetime | None: + times = [_when(c.get("created_at")) for c in comments if isinstance(c, dict) and _login(c.get("user")) == author] + return min((t for t in times if t is not None), default=None) + + +def _latest_by(comments: list, author: str) -> datetime | None: + times = [_when(c.get("created_at")) for c in comments if isinstance(c, dict) and _login(c.get("user")) == author] + return max((t for t in times if t is not None), default=None) + + +def _bot_comment(comments: list) -> BotComment | None: + """The bot's own comment. Only the bot's exact login counts, whatever another comment says.""" + mine = [ + c for c in comments + if isinstance(c, dict) and isinstance(c.get("user"), dict) and c["user"].get("login") == roadmap_model.BOT_LOGIN + and isinstance(c.get("body"), str) + ] + for comment in mine: + if MARKER in comment["body"]: + return BotComment(int(comment["id"]), comment["body"], parse_data_line(comment["body"]), False) + for comment in mine: + body = comment["body"] + if body.startswith(LEGACY_PREFIX) or LEGACY_KEYWORD_MARKER in body: + return BotComment(int(comment["id"]), body, None, True) + return None + + +def _last_unlabel(timeline: list) -> tuple[str, datetime] | None: + """The newest removal of the queue label and who made it. + + Only `unlabeled` events are read, and only their `actor` and `created_at`, which GitHub + sets. A `committed` event's author and committer dates are set by whoever pushed, so + they are never read. + """ + found: list[tuple[datetime, str]] = [] + for event in timeline: + if not isinstance(event, dict) or event.get("event") != "unlabeled": + continue + label = event.get("label") + if not isinstance(label, dict) or label.get("name") != roadmap_model.NEEDS_TRIAGE: + continue + actor, when = _login(event.get("actor")), _when(event.get("created_at")) + if actor is not None and when is not None: + found.append((when, actor)) + if not found: + return None + when, actor = max(found) + return actor, when + + +def _reviews(raw: list) -> tuple[Review, ...]: + """Reviews by people. A deleted account, a bot, and a dismissed review count for nothing.""" + reviews = [] + for review in raw: + if not isinstance(review, dict): + continue + user = review.get("user") + login = _login(user) + if login is None or user.get("type") == "Bot" or review.get("state") == "DISMISSED": + continue + commit = review.get("commit_id") + reviews.append(Review( + login=login, state=str(review.get("state") or ""), association=str(review.get("author_association") or ""), + submitted_at=_when(review.get("submitted_at")), commit_id=commit if isinstance(commit, str) else None, + )) + return tuple(reviews) + + +def _held_use(number: int, author: str, refs: tuple[Reference, ...], listings: Listings) -> dict[int, tuple[int, ...]]: + """For each open accepted issue this pull request names, the author's open pulls naming it. + + This pull request counts from its own fresh title and body, the others from the listing. + """ + wanted = {ref.number for ref in refs if _open_accepted(ref)} + uses: dict[int, set[int]] = {issue_number: {number} for issue_number in wanted} + for pull in listings.open_pulls: + other = int(pull["number"]) + if other == number or _login(pull.get("user")) != author: + continue + named, _dropped = referenced(pull.get("title"), pull.get("body")) + for issue_number, _kind in named: + if issue_number in uses: + uses[issue_number].add(other) + return {issue_number: tuple(sorted(pulls)) for issue_number, pulls in uses.items()} + + +def collect_facts( + run: Runner, number: int, *, listings: Listings, roster: frozenset[str], mode: str, sweep: bool, + event_action: str | None, counter: CallCounter, +) -> Facts: + """Read one pull request's facts: five reads, at most five claim reads, and the record. + + A failed read raises RunnerError. A response in a shape this cannot read raises + RunnerError too, so the caller counts an evaluation error instead of a default fact. + """ + run = counter.wrap(run) + pull = _read_pull(run, number) + files = paginate(run, f"{_API}/pulls/{number}/files?per_page=100") + raw_reviews = paginate(run, f"{_API}/pulls/{number}/reviews?per_page=100") + comments = paginate(run, f"{_API}/issues/{number}/comments?per_page=100") + timeline = paginate(run, f"{_API}/issues/{number}/timeline?per_page=100") + try: + return _assemble(run, number, pull, files, raw_reviews, comments, timeline, listings=listings, roster=roster, + mode=mode, sweep=sweep, event_action=event_action) + except (KeyError, TypeError, ValueError) as exc: + raise RunnerError(f"pull request {number} had a response in an unexpected shape: {type(exc).__name__}") from None + + +def _assemble( + run: Runner, number: int, pull: dict, files: list, raw_reviews: list, comments: list, timeline: list, *, + listings: Listings, roster: frozenset[str], mode: str, sweep: bool, event_action: str | None, +) -> Facts: + author = _login(pull.get("user")) + if author is None: + raise ValueError("pull request has no author") + draft = bool(pull.get("draft")) + title = pull.get("title") or "" + body = (pull.get("body") or "")[: closing_choice.MAX_CHARS] + paths = tuple(f["filename"] for f in files if isinstance(f, dict) and isinstance(f.get("filename"), str)) + changed = pull.get("changed_files") + truncated = len(files) >= MAX_FILES or (isinstance(changed, int) and len(files) < changed) + + pulls = {int(p["number"]): p for p in listings.open_pulls + listings.merged_pulls} + named, dropped = referenced(title, body) + refs = [_resolve(n, kind, listings, pulls) for n, kind in named] + claim_reads = 0 + for index, ref in enumerate(refs): + if claim_reads >= MAX_CLAIM_READS: + break + if not _open_accepted(ref) or author in ref.assignees: + continue + claim_reads += 1 + issue_comments = paginate(run, f"{_API}/issues/{ref.number}/comments?per_page=100") + refs[index] = replace(ref, author_commented_at=_earliest_by(issue_comments, author)) + references = tuple(refs) + + # The record route sits behind the roster in the gate, and bots and drafts never reach + # it, so their merged history is not read. + if author in roster or author in KNOWN_BOTS or draft: + author_record = False + else: + author_record = bool(listings.approved_for(run, author, roster)) + + created_at, updated_at = _when(pull.get("created_at")), _when(pull.get("updated_at")) + if created_at is None or updated_at is None: + raise ValueError("pull request has no timestamps") + return Facts( + number=number, + state=str(pull["state"]), + draft=draft, + author=author, + author_is_bot=pull["user"].get("type") == "Bot", + title=title, + body=body, + base=str(pull["base"]["ref"]), + head_sha=str(pull["head"]["sha"]), + created_at=created_at, + updated_at=updated_at, + labels=_label_names(pull.get("labels")), + paths=paths, + paths_truncated=truncated, + references=references, + references_dropped=dropped, + reviews=_reviews(raw_reviews), + last_unlabel=_last_unlabel(timeline), + last_author_comment_at=_latest_by(comments, author), + bot_comment=_bot_comment(comments), + auto_merge=pull.get("auto_merge") is not None, + author_record=author_record, + held_use=_held_use(number, author, references, listings), + roster=roster, + mode=mode, + sweep=sweep, + event_action=event_action, + ) From d2a22a3553e7e6b3f4c11cc14353a38faa690245 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 20:45:11 -0600 Subject: [PATCH 08/31] Keep draft pull requests out of the held issue cap Signed-off-by: rocklambros --- tests/test_pr_intake.py | 19 +++++++++++++++++++ tools/pr_intake.py | 3 ++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/test_pr_intake.py b/tests/test_pr_intake.py index e9dd7124..91db49a0 100644 --- a/tests/test_pr_intake.py +++ b/tests/test_pr_intake.py @@ -934,6 +934,25 @@ def test_held_use_counts_the_authors_open_pulls_naming_the_issue(): assert got.held_use == {92: (24, 229)} +def test_held_use_ignores_drafts(): + accepted = fixture("accepted_all.json") + for issue_obj in accepted: + if issue_obj["number"] == 282: + issue_obj["assignees"] = [{"login": "login-1", "type": "User"}] + open_pulls = fixture("pulls_open.json") + template = next(p for p in open_pulls if p["number"] == 24) + drafts = [] + for draft_number in (24, 25): + draft = copy.deepcopy(template) + draft.update(number=draft_number, draft=True, body="Refs #282") + drafts.append(draft) + open_pulls = [p for p in open_pulls if p["number"] != 24] + drafts + listings = load_listings(runner_from_fixtures(listing_mapping(accepted=accepted, open_pulls=open_pulls))) + got, _ = collect(pull_mapping(pull=with_body("Refs #282"), reviews=[], timeline=[]), listings=listings) + assert got.held_use == {282: (229,)} + assert outcome(decide(got, TODAY)) == ("pass", "assignment", ()) + + def test_collect_skips_null_review_users_and_bot_reviewers(): got, _ = collect(pull_mapping()) assert len(got.reviews) == 11 diff --git a/tools/pr_intake.py b/tools/pr_intake.py index 23cd0fea..8ebc01a1 100644 --- a/tools/pr_intake.py +++ b/tools/pr_intake.py @@ -1070,12 +1070,13 @@ def _held_use(number: int, author: str, refs: tuple[Reference, ...], listings: L """For each open accepted issue this pull request names, the author's open pulls naming it. This pull request counts from its own fresh title and body, the others from the listing. + A draft is out of the gate's scope, so another draft never takes a slot. """ wanted = {ref.number for ref in refs if _open_accepted(ref)} uses: dict[int, set[int]] = {issue_number: {number} for issue_number in wanted} for pull in listings.open_pulls: other = int(pull["number"]) - if other == number or _login(pull.get("user")) != author: + if other == number or pull.get("draft") or _login(pull.get("user")) != author: continue named, _dropped = referenced(pull.get("title"), pull.get("body")) for issue_number, _kind in named: From b8a08abd5c7827b76222a260b9a5cc6514211454 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 20:48:37 -0600 Subject: [PATCH 09/31] Apply intake decisions with paced, reversible writes Signed-off-by: rocklambros --- tests/test_pr_intake.py | 189 ++++++++++++++++++++++++++++++++++++++++ tools/pr_intake.py | 96 +++++++++++++++++++- 2 files changed, 284 insertions(+), 1 deletion(-) diff --git a/tests/test_pr_intake.py b/tests/test_pr_intake.py index 91db49a0..c294c56d 100644 --- a/tests/test_pr_intake.py +++ b/tests/test_pr_intake.py @@ -1074,3 +1074,192 @@ def test_fixtures_carry_no_email_or_url(): assert not email.search(text), path.name assert not foreign.search(text), path.name assert "http://" not in text, path.name + + +# Applying a decision. The fake below serves scripted `-i` responses in order and records +# every argv with its stdin. It enforces the production allowlist the same way +# `runner_from_fixtures` does, so a test cannot pass on a write shape gh_runner refuses. + +from pr_intake import Decision, WriteError, apply # noqa: E402 + +OK = "HTTP/2.0 200 OK\r\nContent-Type: application/json\r\n\r\n{}" +COMMENT = pr_intake.render_data_line({"reason": "no_accepted"}) + "\n" + pr_intake.MARKER + "\n\nQueued." + + +def write_runner(*responses): + calls: list[tuple[list[str], str | None]] = [] + queue = list(responses) + + def run(args, stdin=None): + gh_runner._check_allowlist(args) + calls.append((list(args), stdin)) + value = queue.pop(0) if queue else OK + return value if isinstance(value, tuple) else (0, value, "") + + run.calls = calls + return run + + +def decision(**overrides) -> Decision: + base = dict( + outcome="queue", route=None, reasons=("no_accepted",), label="keep", comment=None, data=None, + disable_auto_merge=False, notices=(), + ) + base.update(overrides) + return Decision(**base) + + +def raw(status: int, reason: str, body: str, **headers) -> tuple[int, str, str]: + lines = [f"HTTP/2.0 {status} {reason}"] + [f"{name.replace('_', '-')}: {value}" for name, value in headers.items()] + return 1, "\r\n".join(lines) + "\r\n\r\n" + body, "gh: request failed" + + +def bot_comment(comment_id: int = 910003) -> BotComment: + return BotComment(id=comment_id, body=COMMENT, data=parse_data_line(COMMENT), legacy=False) + + +def apply_with(run, f: Facts, d: Decision, **kwargs): + sleeps: list[float] = [] + done = apply(run, f, d, sleep=sleeps.append, **kwargs) + return done, sleeps + + +def test_apply_edits_existing_bot_comment_and_never_posts_twice(): + run = write_runner() + done, _ = apply_with(run, facts(bot_comment=bot_comment()), decision(comment=COMMENT)) + assert done == ["comment_edit"] + assert run.calls == [ + (["api", "-i", "-X", "PATCH", f"{API}/issues/comments/910003", "--input", "-"], json.dumps({"body": COMMENT})) + ] + + +def test_apply_posts_when_no_bot_comment(): + run = write_runner(http({"id": 1}, 201)) + done, _ = apply_with(run, facts(), decision(comment=COMMENT)) + assert done == ["comment_post"] + (args, stdin), = run.calls + assert args == ["api", "-i", "-X", "POST", f"{API}/issues/354/comments", "--input", "-"] + assert json.loads(stdin) == {"body": COMMENT} + + +def test_apply_writes_nothing_on_keep(): + for f, d in ( + (facts(), decision()), + (facts(auto_merge=False), decision(disable_auto_merge=True)), + (facts(auto_merge=True), decision(disable_auto_merge=False)), + ): + run = write_runner() + assert apply_with(run, f, d) == ([], []) + assert run.calls == [] + + +def test_apply_order_is_comment_label_auto_merge(): + run = write_runner(OK, OK, (0, "", ""), http({"auto_merge": None})) + f = facts(auto_merge=True, bot_comment=bot_comment()) + done, _ = apply_with(run, f, decision(comment=COMMENT, label="set", disable_auto_merge=True), pace=0) + assert done == ["comment_edit", "label_set", "auto_merge_off"] + assert [args for args, _ in run.calls] == [ + ["api", "-i", "-X", "PATCH", f"{API}/issues/comments/910003", "--input", "-"], + ["api", "-i", "-X", "POST", f"{API}/issues/354/labels", "--input", "-"], + ["pr", "merge", "354", "--disable-auto", "--repo", roadmap_model.REPO], + ["api", "-i", f"{API}/pulls/354"], + ] + assert json.loads(run.calls[1][1]) == {"labels": [NEEDS_TRIAGE]} + assert run.calls[2][1] is None and run.calls[3][1] is None + + +def test_apply_treats_404_on_label_delete_as_done(): + run = write_runner(raw(404, "Not Found", '{"message":"Label does not exist"}')) + done, _ = apply_with(run, facts(labels=frozenset({NEEDS_TRIAGE})), decision(label="clear")) + assert done == ["label_clear"] + assert run.calls == [(["api", "-i", "-X", "DELETE", f"{API}/issues/354/labels/status%3Aneeds-triage"], None)] + + # A 404 anywhere else is a failure, and the error carries no response text. + run = write_runner(raw(404, "Not Found", '{"message":"secret-ish text"}')) + with pytest.raises(WriteError) as error: + apply_with(run, facts(), decision(label="set")) + assert str(error.value) == "label_set returned HTTP 404" + + +@pytest.mark.parametrize("status", [403, 429]) +def test_apply_honors_retry_after_once_from_the_headers(status): + refused = raw(status, "Forbidden", '{"message":"You have exceeded a secondary rate limit"}', Retry_After="7") + run = write_runner(refused, http({"id": 1}, 201)) + done, sleeps = apply_with(run, facts(), decision(comment=COMMENT)) + assert done == ["comment_post"] + assert sleeps == [7] + assert len(run.calls) == 2 and run.calls[0] == run.calls[1] + + # The retry happens once. A second refusal raises, naming the write and the status only. + run = write_runner(refused, refused, OK) + with pytest.raises(WriteError) as error: + apply_with(run, facts(), decision(comment=COMMENT)) + assert str(error.value) == f"comment_post returned HTTP {status}" + assert len(run.calls) == 2 + + # Without the header there is no retry at all. + run = write_runner(raw(status, "Forbidden", '{"message":"nope"}'), OK) + with pytest.raises(WriteError): + apply_with(run, facts(), decision(comment=COMMENT)) + assert len(run.calls) == 1 + + +def test_apply_raises_without_sleeping_on_a_primary_rate_limit(): + primary = raw(403, "Forbidden", '{"message":"API rate limit exceeded for installation ID 1."}', Retry_After="60") + run = write_runner(primary, OK) + with pytest.raises(WriteError) as error: + apply_with(run, facts(), decision(comment=COMMENT, label="set")) + assert str(error.value) == "rate_limit_primary" + assert len(run.calls) == 1 + + +def test_apply_paces_writes(): + run = write_runner(OK, OK, (0, "", ""), http({"auto_merge": {"merge_method": "squash"}}), (0, "", "")) + f = facts(auto_merge=True) + sleeps: list[float] = [] + done = apply(run, f, decision(comment=COMMENT, label="set", disable_auto_merge=True), pace=2.5, sleep=sleeps.append) + assert done == ["comment_post", "label_set", "auto_merge_off", "converted_to_draft"] + # Four writes, three gaps. The re-read between the last two is not a write. + assert sleeps == [2.5, 2.5, 2.5] + + _, single = apply_with(write_runner(), facts(), decision(comment=COMMENT), pace=2.5) + assert single == [] + + +def test_apply_never_edits_a_contributor_comment_carrying_the_marker_or_the_legacy_prefix(): + contributor_ids = {910001, 910002} + bot_ids = [c["id"] for c in fixture("comments.json") if c["user"]["login"] == roadmap_model.BOT_LOGIN] + renamed = _comments_without(*[i for i in bot_ids if i != 910003]) + for comment in renamed: + if comment["id"] == 910003: + comment["user"]["login"] = "GitHub-Actions[bot]" + + for comments in (_comments_without(*bot_ids), renamed): + got, _ = collect(pull_mapping(comments=comments)) + assert got.bot_comment is None + run = write_runner(http({"id": 1}, 201)) + done, _ = apply_with(run, got, decision(comment=COMMENT)) + assert done == ["comment_post"] + (args, _stdin), = run.calls + assert args[3] == "POST" and args[4] == f"{API}/issues/229/comments" + assert not any(str(i) in arg for arg in args for i in contributor_ids | {910003}) + + +def test_apply_falls_back_to_draft_when_auto_merge_survives_the_disable(): + run = write_runner((0, "", ""), http({"auto_merge": {"merge_method": "squash"}}), (0, "", "")) + done, _ = apply_with(run, facts(auto_merge=True), decision(disable_auto_merge=True)) + assert done == ["auto_merge_off", "converted_to_draft"] + assert [args for args, _ in run.calls] == [ + ["pr", "merge", "354", "--disable-auto", "--repo", roadmap_model.REPO], + ["api", "-i", f"{API}/pulls/354"], + ["pr", "ready", "354", "--undo", "--repo", roadmap_model.REPO], + ] + + run = write_runner((0, "", ""), http({"auto_merge": None})) + done, _ = apply_with(run, facts(auto_merge=True), decision(disable_auto_merge=True)) + assert done == ["auto_merge_off"] and len(run.calls) == 2 + + run = write_runner((1, "", "gh: some failure text")) + with pytest.raises(WriteError) as error: + apply_with(run, facts(auto_merge=True), decision(disable_auto_merge=True)) + assert str(error.value) == "auto_merge_off exited 1" diff --git a/tools/pr_intake.py b/tools/pr_intake.py index 8ebc01a1..e1983751 100644 --- a/tools/pr_intake.py +++ b/tools/pr_intake.py @@ -4,7 +4,8 @@ Version 1.0. Owner: ACS project leads. This module holds the data model, the parsing helpers, the gate, and the collector. `decide` is a pure function of `Facts` and the date, with no I/O. `collect_facts` does every read, through `gh_runner`'s allowlisted shapes, -inside a call budget. The writers join them in a later task of the same plan. +inside a call budget. `apply` makes the writes a decision calls for, in a fixed order +and paced, and reports which ones it made. The workflow runs this under `python3 -I -S` from a checkout of `main`, never the pull request's head, with the standard library and the sibling modules under `tools/` only. @@ -20,6 +21,7 @@ import hashlib import json import re +import time from dataclasses import dataclass, field, replace from datetime import date, datetime, timedelta, timezone @@ -1174,3 +1176,95 @@ def _assemble( sweep=sweep, event_action=event_action, ) + + +# Writing a decision. Comment first, then label, then auto-merge, so a run that stops part +# way leaves the explanation in place before the state it explains. Every API write carries +# `-i`, so its status line is read from stdout rather than from gh's exit code or stderr. + +_PRIMARY_LIMIT = "API rate limit exceeded" +_RETRY_STATUSES = frozenset({403, 429}) +_RETRY_AFTER = re.compile(r"[0-9]{1,6}") + + +class WriteError(RuntimeError): + """A write GitHub refused. The message names the write and the status, never the response.""" + + +def apply(run: Runner, facts: Facts, decision: Decision, *, pace: float = 1.0, sleep=time.sleep) -> list[str]: + """Make the writes `decision` calls for and return their names in the order made. + + The only argv values taken from GitHub are the pull request number and the bot comment + id, both integers. Comment text travels on stdin as JSON. The comment edited is always + `facts.bot_comment`, which collection sets only for a comment whose author is exactly + `BOT_LOGIN`, so a contributor comment that copies the marker is never touched. + """ + number = int(facts.number) + done: list[str] = [] + + def write(name: str, args: list[str], payload: dict | None = None) -> None: + if done: + sleep(pace) + if args[0] == "pr": + _command(run, name, args) + else: + _api_write(run, name, args, None if payload is None else json.dumps(payload), sleep) + done.append(name) + + if decision.comment is not None: + if facts.bot_comment is not None: + path = f"{_API}/issues/comments/{int(facts.bot_comment.id)}" + write("comment_edit", ["api", "-i", "-X", "PATCH", path, "--input", "-"], {"body": decision.comment}) + else: + path = f"{_API}/issues/{number}/comments" + write("comment_post", ["api", "-i", "-X", "POST", path, "--input", "-"], {"body": decision.comment}) + + if decision.label == "set": + path = f"{_API}/issues/{number}/labels" + write("label_set", ["api", "-i", "-X", "POST", path, "--input", "-"], {"labels": [roadmap_model.NEEDS_TRIAGE]}) + elif decision.label == "clear": + path = f"{_API}/issues/{number}/labels/status%3Aneeds-triage" + write("label_clear", ["api", "-i", "-X", "DELETE", path]) + + if decision.disable_auto_merge and facts.auto_merge: + write("auto_merge_off", ["pr", "merge", str(number), "--disable-auto", "--repo", roadmap_model.REPO]) + # The disable is confirmed by reading the pull request back, not trusted from the exit + # code. A draft cannot merge, so converting is the fallback when auto-merge survives. + try: + survived = _read_pull(run, number).get("auto_merge") is not None + except RunnerError: + raise WriteError("auto_merge_off re-read failed") from None + if survived: + write("converted_to_draft", ["pr", "ready", str(number), "--undo", "--repo", roadmap_model.REPO]) + + return done + + +def _command(run: Runner, name: str, args: list[str]) -> None: + """A `gh pr` subcommand, which prints no status line. Its exit code is the result.""" + code, _stdout, _stderr = run(args, None) + if code != 0: + raise WriteError(f"{name} exited {code}") + + +def _api_write(run: Runner, name: str, args: list[str], stdin: str | None, sleep) -> None: + """One API write, retried once when a secondary rate limit names its own wait.""" + for attempt in range(2): + code, stdout, _stderr = run(args, stdin) + if not stdout: + raise WriteError(f"{name} exited {code}") + try: + status, headers, body = split_http(stdout) + except RunnerError: + raise WriteError(f"{name} returned no HTTP status") from None + if 200 <= status < 300 or (status == 404 and name == "label_clear"): + return + # The primary limit resets on a window far longer than a Retry-After wait, so the + # caller stops instead of sleeping. + if status == 403 and _PRIMARY_LIMIT in body: + raise WriteError("rate_limit_primary") + wait = headers.get("retry-after", "") + if attempt == 0 and status in _RETRY_STATUSES and _RETRY_AFTER.fullmatch(wait): + sleep(int(wait)) + continue + raise WriteError(f"{name} returned HTTP {status}") From 58c7f16caa420b3770e6fb54257065eb2d6344f1 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 21:03:38 -0600 Subject: [PATCH 10/31] Run the intake gate on events, nightly, and on dispatch, with arming, dry run, and retract Signed-off-by: rocklambros --- tests/test_pr_intake.py | 561 +++++++++++++++++++++++++++++++++++++ tools/pr_intake.py | 592 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 1136 insertions(+), 17 deletions(-) diff --git a/tests/test_pr_intake.py b/tests/test_pr_intake.py index c294c56d..1abb1b8f 100644 --- a/tests/test_pr_intake.py +++ b/tests/test_pr_intake.py @@ -1263,3 +1263,564 @@ def test_apply_falls_back_to_draft_when_auto_merge_survives_the_disable(): with pytest.raises(WriteError) as error: apply_with(run, facts(auto_merge=True), decision(disable_auto_merge=True)) assert str(error.value) == "auto_merge_off exited 1" + + +# --- Modes, arming, the sweep, retract, and main ----------------------------------------- +# The fake below is a small stateful GitHub: reads serve the pull requests, comments, and +# labels it holds, and writes change them, so a second sweep reads what the first wrote. +# It enforces the production allowlist like the other fakes. + +import hashlib # noqa: E402 +import subprocess # noqa: E402 + +from pr_intake import Summary, arming, retract, run_sweep, tool_sha # noqa: E402 + +SWEEP_TODAY = date(2026, 11, 10) +ENV_KEYS = ( + "PR_NUMBER", "INTAKE_MODE", "INTAKE_AUTHORS", "EVENT_NAME", "EVENT_ACTION", "GITHUB_STEP_SUMMARY", + "PR_INTAKE_ENFORCED", "PR_INTAKE_TOOL_SHA", "GH_TOKEN", +) +OPEN_PULLS = f"{API}/pulls?state=open&per_page=100" +MISMATCH = ( + "::error::PR_INTAKE_TOOL_SHA does not match the armed modules on this ref; dry-sweep and re-arm," + " or unset PR_INTAKE_ENFORCED" +) + + +def gh_pull(number, *, author="outsider", body="b", draft=False, labels=(), updated="2026-11-09T01:00:00Z", + auto_merge=None) -> dict: + return { + "number": number, "state": "open", "draft": draft, "title": "Add a thing", "body": body, + "user": {"login": author, "type": "Bot" if author.endswith("[bot]") else "User"}, + "base": {"ref": "integration"}, "head": {"sha": f"{number:040d}"}, + "created_at": "2026-11-02T12:00:00Z", "updated_at": updated, "changed_files": 1, + "labels": [{"name": name} for name in labels], "auto_merge": auto_merge, + } + + +def gh_issue(number, *, assignees=(), updated="2026-11-01T00:00:00Z") -> dict: + return { + "number": number, "state": "open", "labels": [{"name": roadmap_model.ACCEPTED}], "milestone": None, + "assignees": [{"login": login, "type": "User"} for login in assignees], "updated_at": updated, + "user": {"login": "someone", "type": "User"}, + } + + +def bot_said(body: str, comment_id: int) -> dict: + return {"id": comment_id, "user": {"login": roadmap_model.BOT_LOGIN, "type": "Bot"}, "body": body, + "created_at": "2026-11-03T00:00:00Z"} + + +class FakeGitHub: + def __init__(self, pulls, *, accepted=(), rate=5000): + self.pulls = {pull["number"]: pull for pull in pulls} + self.accepted = list(accepted) + self.comments: dict[int, list] = {} + self.rate = rate + self.fail: dict[str, tuple[int, str, str]] = {} + self.calls: list[tuple[list[str], str | None]] = [] + self.hooks: list = [] + self._ids = iter(range(700_001, 799_999)) + + @staticmethod + def key(args) -> str: + if args[0] == "pr": + return f"pr {args[1]}" + if "-X" in args: + return f"{args[3]} {args[4]}" + return args[-1] + + def __call__(self, args, stdin=None): + gh_runner._check_allowlist(args) + self.calls.append((list(args), stdin)) + for hook in self.hooks: + hook(self, args) + if self.key(args) in self.fail: + return self.fail[self.key(args)] + if args == ["api", "rate_limit"]: + value = self.rate.pop(0) if isinstance(self.rate, list) else self.rate + return 0, json.dumps({"resources": {"core": {"remaining": value}}}), "" + if args[0] == "pr": + pull = self.pulls[int(args[2])] + if args[1] == "merge": + pull["auto_merge"] = None + else: + pull["draft"] = True + return 0, "", "" + if "-X" in args: + return 0, self._write(args[3], args[4][len(API) + 1:], json.loads(stdin) if stdin else None), "" + tail = args[-1][len(API) + 1:] + if args[1] == "-i": + return 0, http(self.pulls[int(tail.split("/")[1])]), "" + return 0, slurp(copy.deepcopy(self._list(tail))), "" + + def _list(self, tail: str) -> list: + path, _, query = tail.partition("?") + if path == "issues": + return self.accepted if "labels=" in query else [i for i in self.accepted if i.get("milestone")] + if path == "pulls": + return [p for p in self.pulls.values() if p["state"] == "open"] if "state=open" in query else [] + _kind, number, what = path.split("/") + if what == "files": + return [{"filename": "tools/x.py"}] + if what == "comments": + return self.comments.get(int(number), []) + return [] + + def _write(self, method: str, tail: str, payload) -> str: + parts = tail.split("/") + if method == "PATCH": + for comments in self.comments.values(): + for comment in comments: + if comment["id"] == int(parts[2]): + comment["body"] = payload["body"] + return OK + number = int(parts[1]) + if parts[2] == "comments": + self.comments.setdefault(number, []).append(bot_said(payload["body"], next(self._ids))) + return http({"id": 1}, 201) + pull = self.pulls[number] + if method == "POST": + pull["labels"].append({"name": payload["labels"][0]}) + else: + pull["labels"] = [label for label in pull["labels"] if label["name"] != NEEDS_TRIAGE] + return OK + + def writes(self) -> list[tuple[list[str], str | None]]: + return [(args, stdin) for args, stdin in self.calls if args[0] == "pr" or "-X" in args] + + def labeled(self, number: int) -> bool: + return NEEDS_TRIAGE in {label["name"] for label in self.pulls[number]["labels"]} + + def bodies(self, number: int) -> list[str]: + return [comment["body"] for comment in self.comments.get(number, [])] + + +@pytest.fixture(autouse=True) +def _no_pacing(monkeypatch): + monkeypatch.setattr(pr_intake, "PACE_SECONDS", 0) + + +def sweep(fake, *, mode="enforced", write=True, numbers=None, counter=None, today=SWEEP_TODAY): + listings = load_listings(fake) + lines: list[str] = [] + summary = run_sweep( + fake, numbers, mode=mode, write=write, listings=listings, roster=ROSTER, today=today, + counter=counter or CallCounter(), log=lines.append, + ) + return summary, lines + + +def balanced(summary: Summary) -> bool: + return summary.evaluated == ( + summary.newly_queued + summary.cleared + summary.unchanged + summary.skipped + summary.out_of_scope + + len(summary.errors) + ) + + +def armed_env() -> dict: + return {"PR_INTAKE_ENFORCED": "true", "PR_INTAKE_TOOL_SHA": tool_sha(pr_intake.REPO_ROOT)} + + +def run_main(monkeypatch, capsys, fake, argv=None, **env): + for key in ENV_KEYS: + monkeypatch.delenv(key, raising=False) + for key, value in env.items(): + monkeypatch.setenv(key, value) + used: list[str] = [] + + def factory(gh=gh_runner.GH, token=None): + used.append(gh) + return fake + + monkeypatch.setattr(pr_intake, "make_runner", factory) + monkeypatch.setattr(pr_intake, "_today", lambda: SWEEP_TODAY) + monkeypatch.setattr(roadmap_model, "trusted_logins", lambda root: ROSTER) + code = pr_intake.main(argv or []) + return code, capsys.readouterr().out, used + + +def summary_line(out: str) -> str: + return next(line for line in out.splitlines() if line.startswith("mode=")) + + +def test_arming_requires_both_variables_and_changes_when_any_of_the_five_modules_changes(tmp_path): + root = Path(__file__).resolve().parents[1] + for module in pr_intake.ARMED_MODULES: + target = tmp_path / module + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes((root / module).read_bytes()) + sha = tool_sha(tmp_path) + assert re.fullmatch(r"[0-9a-f]{40}", sha) and sha == tool_sha(root) + blobs = [ + subprocess.run(["git", "hash-object", str(tmp_path / module)], capture_output=True, text=True, + check=True).stdout.strip() + for module in pr_intake.ARMED_MODULES + ] + assert sha == hashlib.sha256("".join(blobs).encode("ascii")).hexdigest()[:40] + + assert arming({"PR_INTAKE_ENFORCED": "true", "PR_INTAKE_TOOL_SHA": sha}, tmp_path) == "enforced" + for env in ({}, {"PR_INTAKE_TOOL_SHA": sha}, {"PR_INTAKE_ENFORCED": "false", "PR_INTAKE_TOOL_SHA": sha}, + {"PR_INTAKE_ENFORCED": "True", "PR_INTAKE_TOOL_SHA": sha}): + assert arming(env, tmp_path) == "legacy" + for wrong in ("", "0" * 40, sha[:12]): + assert arming({"PR_INTAKE_ENFORCED": "true", "PR_INTAKE_TOOL_SHA": wrong}, tmp_path) == "mismatched" + + seen = {sha} + for module in pr_intake.ARMED_MODULES: + path = tmp_path / module + original = path.read_bytes() + path.write_bytes(original + b"\n") + changed = tool_sha(tmp_path) + assert changed not in seen, module + seen.add(changed) + assert arming({"PR_INTAKE_ENFORCED": "true", "PR_INTAKE_TOOL_SHA": sha}, tmp_path) == "mismatched" + path.write_bytes(original) + assert tool_sha(tmp_path) == sha + + +def identity_fake() -> FakeGitHub: + fake = FakeGitHub( + [ + gh_pull(101, author="rocklambros"), + gh_pull(102), + gh_pull(103, body="Refs #50", labels=(NEEDS_TRIAGE,)), + gh_pull(104, author="dependabot[bot]"), + gh_pull(105, draft=True), + gh_pull(106), + ], + accepted=[gh_issue(50, assignees=("outsider",))], + ) + fake.fail[f"{API}/pulls/106/reviews?per_page=100"] = (1, "", "boom") + return fake + + +def test_sweep_summary_identity_holds(): + fake = identity_fake() + counter = CallCounter() + first, lines = sweep(fake, counter=counter) + assert balanced(first) + assert (first.evaluated, first.newly_queued, first.cleared, first.unchanged, first.skipped, first.out_of_scope) == ( + 6, 1, 1, 1, 0, 2 + ) + assert first.errors == [(106, "RunnerError")] and "::error::#106: RunnerError" in lines + assert first.queued_total == 1 and first.writes == 4 and first.calls == counter.calls + assert fake.labeled(102) and not fake.labeled(103) and not fake.writes()[0][0][4].endswith("/104/comments") + + fake.fail.clear() + second, _ = sweep(fake) + assert balanced(second) + assert (second.newly_queued, second.cleared, second.unchanged, second.out_of_scope, second.errors) == (1, 0, 3, 2, []) + assert second.queued_total == 2 + + +def test_sweep_fails_red_on_a_listing_failure_and_on_a_per_pull_error(monkeypatch, capsys): + fake = FakeGitHub([gh_pull(201), gh_pull(202)]) + fake.fail[f"{API}/pulls/202/reviews?per_page=100"] = (1, "", "secret text") + code, out, _ = run_main(monkeypatch, capsys, fake, EVENT_NAME="schedule", **armed_env()) + assert code == 1 + assert "::error::#202: RunnerError" in out and "secret text" not in out + assert " errors=1 " in summary_line(out) and fake.labeled(201) + + fake = FakeGitHub([gh_pull(201), gh_pull(202)]) + fake.fail[OPEN_PULLS] = (1, "", "boom") + code, out, _ = run_main(monkeypatch, capsys, fake, EVENT_NAME="schedule", **armed_env()) + assert code == 1 and "::error::" in out and "ListingError" in out + assert fake.writes() == [] and not any("/pulls/201" in args[-1] for args, _ in fake.calls) + assert " evaluated=0 " in summary_line(out) + + +@pytest.mark.parametrize("armed", [True, False]) +def test_event_run_exits_zero_on_read_failure_and_writes_nothing(monkeypatch, capsys, armed): + env = armed_env() if armed else {} + for failing in (f"{API}/issues/301/timeline?per_page=100", OPEN_PULLS, f"{API}/pulls/301"): + fake = FakeGitHub([gh_pull(301)]) + fake.fail[failing] = (1, "", "boom") + code, out, _ = run_main(monkeypatch, capsys, fake, EVENT_NAME="pull_request_target", EVENT_ACTION="opened", + PR_NUMBER="301", **env) + assert code == 0, failing + assert "::notice::" in out and "::error::" not in out and "boom" not in out + assert fake.writes() == [] + + +def test_event_run_is_red_when_auto_merge_disable_fails(monkeypatch, capsys): + on = {"merge_method": "squash"} + fake = FakeGitHub([gh_pull(311, auto_merge=on)]) + fake.fail["pr merge"] = (1, "", "boom") + code, out, _ = run_main(monkeypatch, capsys, fake, EVENT_NAME="pull_request_target", EVENT_ACTION="edited", + PR_NUMBER="311", **armed_env()) + assert code == 1 and "::error::#311" in out and "boom" not in out + assert fake.labeled(311) and fake.bodies(311) + + # A failed label write still leaves room for the disable, which is what keeps the run green. + fake = FakeGitHub([gh_pull(312, auto_merge=on)]) + fake.fail[f"POST {API}/issues/312/labels"] = (1, "HTTP/2.0 500 Error\r\n\r\n{}", "") + code, out, _ = run_main(monkeypatch, capsys, fake, EVENT_NAME="pull_request_target", EVENT_ACTION="opened", + PR_NUMBER="312", **armed_env()) + assert code == 0 and "::notice::" in out + assert fake.pulls[312]["auto_merge"] is None + + fake = FakeGitHub([gh_pull(313, auto_merge=on)]) + fake.fail[f"POST {API}/issues/313/labels"] = (1, "HTTP/2.0 500 Error\r\n\r\n{}", "") + fake.fail["pr merge"] = (1, "", "boom") + code, out, _ = run_main(monkeypatch, capsys, fake, EVENT_NAME="pull_request_target", EVENT_ACTION="opened", + PR_NUMBER="313", **armed_env()) + assert code == 1 and "::error::#313" in out + + +def test_synchronize_without_the_box_short_circuits(monkeypatch, capsys): + fake = FakeGitHub([gh_pull(321)]) + code, out, _ = run_main(monkeypatch, capsys, fake, EVENT_NAME="pull_request_target", EVENT_ACTION="synchronize", + PR_NUMBER="321", **armed_env()) + assert code == 0 and "::notice::#321" in out + assert [args for args, _ in fake.calls] == [["api", "-i", f"{API}/pulls/321"]] + + ticked = FakeGitHub([gh_pull(322, body=TICKED)]) + code, out, _ = run_main(monkeypatch, capsys, ticked, EVENT_NAME="pull_request_target", + EVENT_ACTION="synchronize", PR_NUMBER="322", **armed_env()) + assert code == 0 and "#322 queue editorial_off_lane" in out + assert ticked.labeled(322) and OPEN_PULLS in [args[-1] for args, _ in ticked.calls] + + +def test_sweep_skips_a_pull_request_changed_during_the_sweep(): + fake = FakeGitHub([gh_pull(401), gh_pull(402)]) + + def push_during_collection(state, args): + if args[-1] == f"{API}/issues/401/timeline?per_page=100": + state.pulls[401]["updated_at"] = "2026-11-10T03:00:00Z" + + fake.hooks.append(push_during_collection) + summary, lines = sweep(fake) + assert summary.skipped == 1 and summary.newly_queued == 1 and balanced(summary) + assert not summary.stopped and not summary.errors + assert not fake.labeled(401) and not fake.bodies(401) + assert fake.labeled(402) and fake.bodies(402) + assert any(line.startswith("#401 skipped") for line in lines) + + +def test_sweep_reads_only_the_comment_and_the_pull_for_an_unchanged_pull_request(): + fake = FakeGitHub([gh_pull(501, body="Refs #60"), gh_pull(502, author="rocklambros")], + accepted=[gh_issue(60)]) + first, _ = sweep(fake) + assert first.newly_queued == 1 + data = parse_data_line(fake.bodies(501)[0]) + assert data["reason"] == "not_held" and data["seen"] == "2026-11-09T01:00:00Z,2026-11-01T00:00:00Z" + + fake.calls.clear() + second, lines = sweep(fake) + reads = [args[-1] for args, _ in fake.calls if "/501" in args[-1]] + assert reads == [f"{API}/issues/501/comments?per_page=100", f"{API}/pulls/501"] + assert second.unchanged == 2 and second.queued_total == 1 and second.writes == 0 and balanced(second) + assert "#501 unchanged not_held" in lines + + # A change on the referenced issue moves `seen`, so the pull request is read in full. + fake.accepted[0]["updated_at"] = "2026-11-09T05:00:00Z" + fake.calls.clear() + sweep(fake) + assert f"{API}/issues/501/timeline?per_page=100" in [args[-1] for args, _ in fake.calls] + + # And a label that no longer matches the reason is read in full and repaired. + fake.accepted[0]["updated_at"] = "2026-11-01T00:00:00Z" + fake.pulls[501]["labels"] = [] + third, _ = sweep(fake) + assert fake.labeled(501) and third.newly_queued == 1 + + +def test_sweep_stops_at_the_budget_reserve_and_is_red(monkeypatch, capsys): + pulls = [gh_pull(600 + n) for n in range(12)] + fake = FakeGitHub(pulls, rate=[1000, pr_intake.BUDGET_RESERVE - 1]) + summary, lines = sweep(fake) + assert summary.stopped and summary.skipped == 2 and summary.newly_queued == 10 and balanced(summary) + assert summary.rate_limit_remaining == pr_intake.BUDGET_RESERVE - 1 + assert not fake.bodies(610) and not fake.bodies(611) and fake.bodies(609) + assert any(line.startswith("::error::") for line in lines) + + # Past the call budget: reading stops early enough that the writes still fit. + fake = FakeGitHub([gh_pull(600 + n) for n in range(12)]) + counter = CallCounter(limit=pr_intake.BUDGET_RESERVE + 8) + summary, _ = sweep(fake, counter=counter) + assert summary.stopped and summary.skipped == 10 and summary.newly_queued == 2 and balanced(summary) + assert counter.calls <= counter.limit + + fake = FakeGitHub([gh_pull(600 + n) for n in range(3)], rate=pr_intake.BUDGET_RESERVE - 1) + code, out, _ = run_main(monkeypatch, capsys, fake, EVENT_NAME="schedule", **armed_env()) + assert code == 1 and fake.writes() == [] and " skipped=3 " in summary_line(out) + + +def test_scheduled_sweep_is_red_when_enforcement_is_requested_but_unarmed(monkeypatch, capsys): + fake = FakeGitHub([gh_pull(651), gh_pull(652, author="rocklambros")]) + code, out, _ = run_main(monkeypatch, capsys, fake, EVENT_NAME="schedule", PR_INTAKE_ENFORCED="true", + PR_INTAKE_TOOL_SHA="0" * 40) + assert code == 1 and MISMATCH in out.splitlines() + assert fake.writes() == [] + assert summary_line(out).startswith("mode=mismatched ") + + # An event run in the same state runs the legacy pass and stays green. + fake = FakeGitHub([gh_pull(653)]) + code, out, _ = run_main(monkeypatch, capsys, fake, EVENT_NAME="pull_request_target", EVENT_ACTION="opened", + PR_NUMBER="653", PR_INTAKE_ENFORCED="true", PR_INTAKE_TOOL_SHA="0" * 40) + assert code == 0 and fake.bodies(653)[0].startswith(pr_intake.LEGACY_PREFIX) + + +def test_summary_line_names_the_mode(monkeypatch, capsys, tmp_path): + step = tmp_path / "summary.md" + sha = tool_sha(pr_intake.REPO_ROOT) + fake = FakeGitHub([gh_pull(661), gh_pull(662, author="dependabot[bot]")]) + code, out, used = run_main(monkeypatch, capsys, fake, ["--gh", "/opt/gh"], EVENT_NAME="schedule", + GITHUB_STEP_SUMMARY=str(step)) + assert code == 0 and used == ["/opt/gh"] and fake.writes() == [] + line = summary_line(out) + assert line == ( + f"mode=legacy tool_sha={sha[:12]} evaluated=2 newly_queued=0 queued_total=0 cleared=0 unchanged=1" + f" skipped=0 out_of_scope=1 errors=0 writes=0 calls={len(fake.calls)} rate_limit_remaining=5000" + ) + assert line in step.read_text().splitlines() + assert "outsider" not in out and "dependabot" not in out + + code, out, used = run_main(monkeypatch, capsys, FakeGitHub([gh_pull(663)]), EVENT_NAME="schedule", + GITHUB_STEP_SUMMARY=str(step), **armed_env()) + assert code == 0 and used == [gh_runner.GH] + assert summary_line(out).startswith(f"mode=enforced tool_sha={sha[:12]} evaluated=1 newly_queued=1 ") + assert sum(text.startswith("mode=") for text in step.read_text().splitlines()) == 2 + + +@pytest.mark.parametrize("armed", [True, False]) +def test_dryrun_writes_nothing_and_prints_every_decision_and_the_arm_value(monkeypatch, capsys, armed): + env = armed_env() if armed else {} + fake = FakeGitHub([gh_pull(671), gh_pull(672, author="rocklambros"), gh_pull(673, draft=True), + gh_pull(674, body="Refs #60", labels=(NEEDS_TRIAGE,), auto_merge={"merge_method": "squash"})], + accepted=[gh_issue(60)]) + code, out, _ = run_main(monkeypatch, capsys, fake, EVENT_NAME="workflow_dispatch", INTAKE_MODE="dryrun", **env) + assert code == 0 and fake.writes() == [] + lines = out.splitlines() + for expected in ("#671 queue no_accepted", "#672 pass roster", "#673 out_of_scope", "#674 queue not_held"): + assert expected in lines + assert f"arm with PR_INTAKE_TOOL_SHA={tool_sha(pr_intake.REPO_ROOT)}" in lines + assert " newly_queued=1 queued_total=2 " in summary_line(out) and " writes=0 " in summary_line(out) + assert MISMATCH not in out + + +def test_retract_refuses_while_enforced(monkeypatch, capsys): + fake = FakeGitHub([gh_pull(681, labels=(NEEDS_TRIAGE,))]) + code, out, _ = run_main(monkeypatch, capsys, fake, EVENT_NAME="workflow_dispatch", INTAKE_MODE="retract", + **armed_env()) + assert code == 1 and "::error::Unset PR_INTAKE_ENFORCED before retracting" in out.splitlines() + assert fake.calls == [] + + +def test_retract_keeps_the_marker_so_the_next_queue_edits_not_posts(monkeypatch, capsys): + fake = FakeGitHub([gh_pull(691), gh_pull(692, author="rocklambros")]) + code, _, _ = run_main(monkeypatch, capsys, fake, EVENT_NAME="schedule", **armed_env()) + assert code == 0 and fake.labeled(691) and len(fake.bodies(691)) == 1 + + code, out, _ = run_main(monkeypatch, capsys, fake, EVENT_NAME="workflow_dispatch", INTAKE_MODE="retract") + assert code == 0 and not fake.labeled(691) + (body,) = fake.bodies(691) + lines = body.split("\n") + assert parse_data_line(lines[0]) == {"reason": "retracted", "since": SWEEP_TODAY.isoformat()} + assert lines[1] == pr_intake.MARKER and "Retracted. This pull request is not queued." in body + assert " cleared=1 " in summary_line(out) and " unchanged=1 " in summary_line(out) + + fake.calls.clear() + code, _, _ = run_main(monkeypatch, capsys, fake, EVENT_NAME="schedule", **armed_env()) + assert code == 0 and fake.labeled(691) and len(fake.bodies(691)) == 1 + written = [args[3:5] for args, _ in fake.writes()] + assert ["PATCH", f"{API}/issues/comments/{fake.comments[691][0]['id']}"] in written + assert ["POST", f"{API}/issues/691/comments"] not in written + + +def test_retract_scoped_to_bots_touches_only_known_bots(): + def population() -> FakeGitHub: + fake = FakeGitHub([ + gh_pull(801, author="dependabot[bot]", labels=(NEEDS_TRIAGE,)), + gh_pull(802, labels=(NEEDS_TRIAGE,)), + gh_pull(803, author="github-actions[bot]", labels=(NEEDS_TRIAGE,)), + ]) + fake.comments[801] = [bot_said(pr_intake.LEGACY_COMMENT, 1)] + fake.comments[802] = [bot_said(COMMENT, 2)] + return fake + + fake = population() + summary = retract(fake, [801, 802, 803], authors="bots", today=SWEEP_TODAY, log=lambda line: None) + assert (summary.cleared, summary.unchanged, summary.errors) == (2, 1, []) and balanced(summary) + assert not fake.labeled(801) and not fake.labeled(803) and fake.labeled(802) + assert parse_data_line(fake.bodies(801)[0])["reason"] == "retracted" + assert fake.bodies(802) == [COMMENT] and fake.bodies(803) == [] + assert not any(args[3] == "POST" and args[4].endswith("/comments") for args, _ in fake.writes()) + + fake = population() + summary = retract(fake, [801, 802, 803], authors="all", today=SWEEP_TODAY, log=lambda line: None) + assert (summary.cleared, summary.unchanged) == (1, 2) + assert fake.labeled(801) and fake.labeled(803) and not fake.labeled(802) + assert fake.bodies(801) == [pr_intake.LEGACY_COMMENT] + + +def test_any_sweep_batches_an_author_with_many_queues(): + busy = [gh_pull(900 + n, author="busy") for n in range(1, 6)] + calm = [gh_pull(910 + n, author="calm") for n in range(1, 4)] + fake = FakeGitHub(busy + calm) + summary, _ = sweep(fake) + assert summary.newly_queued == 8 + (newest,) = fake.bodies(905) + assert pr_intake.QUEUED_LINE in newest + for number in range(901, 905): + (body,) = fake.bodies(number) + lines = body.split("\n") + assert parse_data_line(lines[0])["reason"] == "no_accepted" and lines[1] == pr_intake.MARKER + assert lines[2:] == ["", "Queued for the reason given on PR #905."] + assert fake.labeled(number) + for number in range(911, 914): + assert pr_intake.QUEUED_LINE in fake.bodies(number)[0] + + +def test_roster_unparsable_produces_zero_writes_and_a_warning(monkeypatch, capsys): + def broken(root): + raise roadmap_model.RosterError("a roster row did not parse: someone") + + for event, extra, expected in ( + ("pull_request_target", {"PR_NUMBER": "951", "EVENT_ACTION": "opened"}, 0), + ("schedule", {}, 1), + ): + fake = FakeGitHub([gh_pull(951)]) + for key in ENV_KEYS: + monkeypatch.delenv(key, raising=False) + for key, value in {"EVENT_NAME": event, **extra, **armed_env()}.items(): + monkeypatch.setenv(key, value) + monkeypatch.setattr(pr_intake, "make_runner", lambda gh=gh_runner.GH, token=None: fake) + monkeypatch.setattr(roadmap_model, "trusted_logins", broken) + assert pr_intake.main([]) == expected + out = capsys.readouterr().out + assert "::warning::" in out and "RosterError" in out and "someone" not in out + assert fake.calls == [] + + +def test_sweep_over_the_population_reports_the_expected_summary(monkeypatch): + rows = json.loads(POPULATION.read_text())["rows"] + by_number = {row["facts"]["number"]: _from_json(row["facts"]) for row in rows} + pulls = [] + for number, f in by_number.items(): + pull = gh_pull(number, author=f.author, draft=f.draft, labels=tuple(f.labels)) + pulls.append(pull) + fake = FakeGitHub(pulls) + monkeypatch.setattr(pr_intake, "collect_facts", lambda run, number, **kwargs: by_number[number]) + today = date.fromisoformat(rows[0]["today"]) + summary, lines = sweep(fake, write=False, today=today) + + expected = {"newly_queued": 0, "cleared": 0, "unchanged": 0, "out_of_scope": 0, "queued_total": 0} + for row in rows: + f, outcome_ = row["facts"], row["expected"]["outcome"] + labeled = NEEDS_TRIAGE in f["labels"] + if f["author"] in pr_intake.KNOWN_BOTS or f["draft"]: + expected["out_of_scope"] += 1 + continue + if outcome_ == "queue": + expected["queued_total"] += 1 + expected["newly_queued" if not labeled else "unchanged"] += 1 + else: + expected["cleared" if labeled else "unchanged"] += 1 + assert expected == {"newly_queued": 29, "cleared": 1, "unchanged": 15, "out_of_scope": 11, "queued_total": 32} + got = {key: getattr(summary, key) for key in expected} + assert got == expected + assert summary.evaluated == len(rows) == 56 and summary.writes == 0 and not summary.errors and balanced(summary) + assert fake.writes() == [] + assert len([line for line in lines if re.fullmatch(r"#[0-9]+ (queue|pass|out_of_scope)( [a-z_,]+)?", line)]) == 56 diff --git a/tools/pr_intake.py b/tools/pr_intake.py index e1983751..cb50e3cb 100644 --- a/tools/pr_intake.py +++ b/tools/pr_intake.py @@ -5,10 +5,14 @@ helpers, the gate, and the collector. `decide` is a pure function of `Facts` and the date, with no I/O. `collect_facts` does every read, through `gh_runner`'s allowlisted shapes, inside a call budget. `apply` makes the writes a decision calls for, in a fixed order -and paced, and reports which ones it made. - -The workflow runs this under `python3 -I -S` from a checkout of `main`, never the pull -request's head, with the standard library and the sibling modules under `tools/` only. +and paced, and reports which ones it made. `main` runs one pull request on an event, every +open pull request on a sweep, a dry run, or a retraction, in the mode `arming` reads from +two repository variables. + +The workflow runs this under `python3 -I -S` from a checkout of a protected base ref, never +the pull request's head, with the standard library and the sibling modules under `tools/` +only. `-I` drops the script's own directory from the import path, so it is added back +explicitly below. Everything read from GitHub is untrusted text: titles, bodies, comments, and file paths are attacker-controlled. Two rules follow. Free text never reaches a command line or a log line unescaped, which is why `safe_path` exists. And the bot's own comment carries @@ -18,17 +22,24 @@ """ from __future__ import annotations -import hashlib -import json -import re -import time -from dataclasses import dataclass, field, replace -from datetime import date, datetime, timedelta, timezone +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) -import base_branch_guard -import closing_choice -import roadmap_model -from gh_runner import Runner, RunnerError, paginate, split_http +import argparse # noqa: E402 +import hashlib # noqa: E402 +import json # noqa: E402 +import os # noqa: E402 +import re # noqa: E402 +import time # noqa: E402 +from dataclasses import dataclass, field, replace # noqa: E402 +from datetime import date, datetime, timedelta, timezone # noqa: E402 + +import base_branch_guard # noqa: E402 +import closing_choice # noqa: E402 +import roadmap_model # noqa: E402 +from gh_runner import GH, Runner, RunnerError, make_runner, paginate, remaining, split_http # noqa: E402 # The marker sits on line 2 of the bot comment. The data line sits on line 1, ahead of it. MARKER = "" @@ -1089,18 +1100,22 @@ def _held_use(number: int, author: str, refs: tuple[Reference, ...], listings: L def collect_facts( run: Runner, number: int, *, listings: Listings, roster: frozenset[str], mode: str, sweep: bool, - event_action: str | None, counter: CallCounter, + event_action: str | None, counter: CallCounter, pull: dict | None = None, comments: list | None = None, ) -> Facts: """Read one pull request's facts: five reads, at most five claim reads, and the record. A failed read raises RunnerError. A response in a shape this cannot read raises RunnerError too, so the caller counts an evaluation error instead of a default fact. + A sweep has already read the pull request and its comments to test `seen`, and passes + them in rather than reading them twice. """ run = counter.wrap(run) - pull = _read_pull(run, number) + if pull is None: + pull = _read_pull(run, number) files = paginate(run, f"{_API}/pulls/{number}/files?per_page=100") raw_reviews = paginate(run, f"{_API}/pulls/{number}/reviews?per_page=100") - comments = paginate(run, f"{_API}/issues/{number}/comments?per_page=100") + if comments is None: + comments = paginate(run, f"{_API}/issues/{number}/comments?per_page=100") timeline = paginate(run, f"{_API}/issues/{number}/timeline?per_page=100") try: return _assemble(run, number, pull, files, raw_reviews, comments, timeline, listings=listings, roster=roster, @@ -1268,3 +1283,546 @@ def _api_write(run: Runner, name: str, args: list[str], stdin: str | None, sleep sleep(int(wait)) continue raise WriteError(f"{name} returned HTTP {status}") + + +# --- Modes, the sweep, retract, and main ------------------------------------------------ + +# Every module whose code decides or writes. A change to any of them disarms enforcement +# until a lead dry-sweeps the new code and copies the new value into PR_INTAKE_TOOL_SHA. +ARMED_MODULES = ( + "tools/pr_intake.py", + "tools/gh_runner.py", + "tools/closing_choice.py", + "tools/base_branch_guard.py", + "tools/roadmap_model.py", +) +REPO_ROOT = Path(__file__).resolve().parents[1] +# Seconds between writes. Tests set it to zero. +PACE_SECONDS = 1.0 +BUDGET_EVERY = 10 +BATCH_OVER = 3 +EVENT_NAMES = frozenset({"pull_request_target", "pull_request_review"}) +SWEEP_NAMES = frozenset({"schedule", "workflow_dispatch"}) +INTAKE_MODES = frozenset({"evaluate", "dryrun", "retract"}) +AUTHOR_SCOPES = frozenset({"all", "bots"}) +QUEUED_ELSEWHERE = "Queued for the reason given on PR #{number}." +RETRACTED_LINE = "Retracted. This pull request is not queued." +MISMATCH_ERROR = ( + "::error::PR_INTAKE_TOOL_SHA does not match the armed modules on this ref; dry-sweep and re-arm," + " or unset PR_INTAKE_ENFORCED" +) +_QUEUE_REASONS = frozenset(REASONS) - {"retracted"} +_DIGITS = re.compile(r"[0-9]{1,9}") +# What one pull request's evaluation can raise without stopping the others. The class name +# is printed, never the message, which may carry response text. +_PER_PULL_ERRORS = (RunnerError, WriteError, KeyError, TypeError, ValueError) +_AUTO_MERGE_WRITES = frozenset({"auto_merge_off", "converted_to_draft"}) + + +def _blob_sha(data: bytes) -> str: + """The object id `git hash-object` prints for these bytes, so the job needs no git.""" + return hashlib.sha1(b"blob %d\0" % len(data) + data, usedforsecurity=False).hexdigest() + + +def tool_sha(repo: Path) -> str: + """Forty hex characters naming the exact armed modules at `repo`.""" + blobs = "".join(_blob_sha((repo / module).read_bytes()) for module in ARMED_MODULES) + return hashlib.sha256(blobs.encode("ascii")).hexdigest()[:40] + + +def arming(env, repo: Path) -> str: + """"enforced" when both variables arm this exact code, "mismatched" when only the switch is + on, and "legacy" otherwise.""" + if env.get("PR_INTAKE_ENFORCED") != "true": + return "legacy" + return "enforced" if env.get("PR_INTAKE_TOOL_SHA", "") == tool_sha(repo) else "mismatched" + + +@dataclass +class Summary: + """One run's counts. Every evaluated pull request lands in exactly one bucket.""" + + mode: str + tool_sha: str + evaluated: int = 0 + newly_queued: int = 0 + queued_total: int = 0 + cleared: int = 0 + unchanged: int = 0 + skipped: int = 0 + out_of_scope: int = 0 + errors: list[tuple[int, str]] = field(default_factory=list) + writes: int = 0 + calls: int = 0 + rate_limit_remaining: int | None = None + # Not a count: whether a budget stopped the run early, which makes it red. + stopped: bool = False + + def line(self) -> str: + rate = "unknown" if self.rate_limit_remaining is None else str(self.rate_limit_remaining) + return ( + f"mode={self.mode} tool_sha={self.tool_sha[:12]} evaluated={self.evaluated}" + f" newly_queued={self.newly_queued} queued_total={self.queued_total} cleared={self.cleared}" + f" unchanged={self.unchanged} skipped={self.skipped} out_of_scope={self.out_of_scope}" + f" errors={len(self.errors)} writes={self.writes} calls={self.calls} rate_limit_remaining={rate}" + ) + + +def seen_value(pull: dict, listings: Listings) -> str | None: + """The pull request's `updated_at` and the newest `updated_at` of the accepted issues it names. + + A sweep compares this with the data line's `seen` and reads nothing more when they + match. A comment, push, review, or label moves the first half. A label, assignment, or + comment on a referenced issue moves the second. + """ + updated = pull.get("updated_at") + if not isinstance(updated, str): + return None + title, body = pull.get("title"), pull.get("body") + named, _dropped = referenced( + title if isinstance(title, str) else "", body[: closing_choice.MAX_CHARS] if isinstance(body, str) else "" + ) + times = [listings.accepted[number].get("updated_at") for number, _kind in named if number in listings.accepted] + newest = max((when for when in times if isinstance(when, str)), default="none") + return f"{updated},{newest}" + + +def _with_seen(decision: Decision, seen: str | None) -> Decision: + """The decision with `seen` on the data line of any comment it writes.""" + if decision.comment is None or decision.data is None or seen is None: + return decision + data = {**decision.data, "seen": seen} + try: + line = render_data_line(data) + except ValueError: + # A timestamp this line cannot carry. The comment goes out without `seen`, and the + # next sweep reads the pull request in full, which is the safe direction. + return decision + rest = decision.comment.split("\n", 1)[1] + return replace(decision, data=data, comment=(line + "\n" + rest)[:MAX_COMMENT]) + + +def _label_agrees(reason: str | None, labels: frozenset[str]) -> bool: + if reason == "cleared": + return roadmap_model.NEEDS_TRIAGE not in labels + return reason in _QUEUE_REASONS and roadmap_model.NEEDS_TRIAGE in labels + + +@dataclass +class _Assessment: + """One pull request after reading and deciding. `decision` is None when `seen` matched.""" + + number: int + facts: Facts | None + decision: Decision | None + updated_at: object + stored_reason: str | None = None + + +def _assess( + run: Runner, number: int, *, listings: Listings, roster: frozenset[str], mode: str, today: date, sweep: bool, + event_action: str | None, counter: CallCounter, short_circuit: bool, +) -> _Assessment: + counted = counter.wrap(run) + comments = paginate(counted, f"{_API}/issues/{number}/comments?per_page=100") + pull = _read_pull(counted, number) + seen = seen_value(pull, listings) + if short_circuit and seen is not None: + bot = _bot_comment(comments) + stored = bot.data if bot is not None and bot.data is not None else {} + if stored.get("seen") == seen and _label_agrees(stored.get("reason"), _label_names(pull.get("labels"))): + return _Assessment(number, None, None, pull.get("updated_at"), stored["reason"]) + facts = collect_facts( + run, number, listings=listings, roster=roster, mode=mode, sweep=sweep, event_action=event_action, + counter=counter, pull=pull, comments=comments, + ) + return _Assessment(number, facts, _with_seen(decide(facts, today), seen), pull.get("updated_at")) + + +def _has_writes(facts: Facts, decision: Decision) -> bool: + return decision.comment is not None or decision.label != "keep" or (decision.disable_auto_merge and facts.auto_merge) + + +def _is_auto_merge_failure(exc: WriteError) -> bool: + return str(exc).split(" ", 1)[0] in _AUTO_MERGE_WRITES + + +def _write(run: Runner, facts: Facts, decision: Decision, counter: CallCounter) -> list[str]: + counted = counter.wrap(run) + try: + return apply(counted, facts, decision, pace=PACE_SECONDS) + except WriteError as exc: + # A failed comment or label write stops `apply` before the auto-merge write, and + # auto-merge left on is the harm the gate exists to prevent, so it is still tried. + if decision.disable_auto_merge and facts.auto_merge and not _is_auto_merge_failure(exc): + apply(counted, facts, replace(decision, comment=None, label="keep"), pace=PACE_SECONDS) + raise + + +def evaluate_one( + run: Runner, number: int, *, listings: Listings, roster: frozenset[str], mode: str, today: date, sweep: bool, + event_action: str | None, counter: CallCounter, write: bool, +) -> tuple[Decision, list[str]]: + """Read, decide, and, when `write`, apply one pull request. Returns the writes made.""" + assessment = _assess( + run, number, listings=listings, roster=roster, mode=mode, today=today, sweep=sweep, + event_action=event_action, counter=counter, short_circuit=False, + ) + facts, decision = assessment.facts, assessment.decision + if not write or not _has_writes(facts, decision): + return decision, [] + return decision, _write(run, facts, decision, counter) + + +def _decision_line(number: int, decision: Decision) -> str: + """Numbers, outcomes, routes, and reason codes only. Legacy adds its would-be decision.""" + detail = ",".join(decision.reasons) if decision.outcome == "queue" else decision.route or "none" + line = f"#{number} {decision.outcome} {detail}" + return line + "".join(f" ({notice})" for notice in decision.notices) + + +def _error(summary: Summary, number: int, exc: BaseException, log) -> None: + summary.errors.append((number, type(exc).__name__)) + log(f"::error::#{number}: {type(exc).__name__}") + + +def _newly_queued(facts: Facts, decision: Decision) -> bool: + return decision.outcome == "queue" and decision.label == "set" and roadmap_model.NEEDS_TRIAGE not in facts.labels + + +def _batch(assessed: list[_Assessment]) -> None: + """Give an author with many new queues one full comment, on the newest, and a pointer on the rest.""" + by_author: dict[str, list[_Assessment]] = {} + for item in assessed: + if item.decision is not None and item.decision.comment is not None and _newly_queued(item.facts, item.decision): + by_author.setdefault(item.facts.author, []).append(item) + for group in by_author.values(): + if len(group) <= BATCH_OVER: + continue + newest = max(item.number for item in group) + for item in group: + if item.number != newest: + line = item.decision.comment.split("\n", 1)[0] + text = f"{line}\n{MARKER}\n\n{QUEUED_ELSEWHERE.format(number=newest)}" + item.decision = replace(item.decision, comment=text) + + +def _count(summary: Summary, facts: Facts, decision: Decision) -> None: + labeled = roadmap_model.NEEDS_TRIAGE in facts.labels + if _newly_queued(facts, decision): + summary.newly_queued += 1 + elif decision.outcome == "pass" and decision.label == "clear": + summary.cleared += 1 + else: + summary.unchanged += 1 + if decision.label == "set" or (labeled and decision.label != "clear"): + summary.queued_total += 1 + + +def run_sweep( + run: Runner, numbers: list[int] | None, *, mode: str, write: bool, listings: Listings, roster: frozenset[str], + today: date, counter: CallCounter | None = None, arming_mode: str | None = None, sha: str = "", log=print, +) -> Summary: + """Evaluate every open pull request, or `numbers`, then make the writes in a second pass. + + Reading comes first so an author's new queues can be batched before anything is posted. + Reading stops BUDGET_RESERVE calls short of the counter's limit, or when the rate limit + left falls below BUDGET_RESERVE, so the writes for what was read still fit. + """ + counter = counter or CallCounter() + counted = counter.wrap(run) + summary = Summary(mode=arming_mode or mode, tool_sha=sha) + if numbers is None: + numbers = [] + for pull in listings.open_pulls: + if _login(pull.get("user")) in KNOWN_BOTS or pull.get("draft"): + summary.out_of_scope += 1 + log(f"#{int(pull['number'])} out_of_scope") + else: + numbers.append(int(pull["number"])) + summary.evaluated = summary.out_of_scope + len(numbers) + + read_limit = max(0, counter.limit - BUDGET_RESERVE) + assessed: list[_Assessment] = [] + for index, number in enumerate(numbers): + try: + if index % BUDGET_EVERY == 0: + summary.rate_limit_remaining = remaining(counted) + low = summary.rate_limit_remaining is not None and summary.rate_limit_remaining < BUDGET_RESERVE + if low or counter.calls >= read_limit: + raise BudgetStop("read budget reached") + assessed.append(_assess( + run, number, listings=listings, roster=roster, mode=mode, today=today, sweep=True, + event_action=None, counter=counter, short_circuit=mode == "enforced", + )) + except BudgetStop: + summary.stopped = True + summary.skipped += len(numbers) - index + log(f"::error::The budget ran out before {len(numbers) - index} pull requests were evaluated.") + break + except _PER_PULL_ERRORS as exc: + _error(summary, number, exc, log) + + if mode == "enforced": + _batch(assessed) + + for item in assessed: + if item.decision is None: + summary.unchanged += 1 + summary.queued_total += item.stored_reason in _QUEUE_REASONS + log(f"#{item.number} unchanged {item.stored_reason}") + continue + facts, decision = item.facts, item.decision + log(_decision_line(item.number, decision)) + if facts.author in KNOWN_BOTS or facts.draft: + summary.out_of_scope += 1 + continue + if write and _has_writes(facts, decision): + try: + # Staleness guard: a pull request that moved since it was read is left for + # the next run, which reads it fresh. + if _read_pull(counted, item.number).get("updated_at") != item.updated_at: + summary.skipped += 1 + log(f"#{item.number} skipped, changed during the sweep") + continue + summary.writes += len(_write(run, facts, decision, counter)) + except BudgetStop: + if not summary.stopped: + log("::error::The call budget ran out before every write was made.") + summary.stopped = True + summary.skipped += 1 + continue + except _PER_PULL_ERRORS as exc: + _error(summary, item.number, exc, log) + continue + _count(summary, facts, decision) + summary.calls = counter.calls + return summary + + +_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) + + +def _retract_facts(number: int, pull: dict, bot: BotComment | None, labels: frozenset[str]) -> Facts: + """The facts `apply` reads for a retraction: the number, the bot comment, and no auto-merge.""" + return Facts( + number=number, state=str(pull.get("state") or ""), draft=bool(pull.get("draft")), + author=_login(pull.get("user")) or "", author_is_bot=False, title="", body="", base="", head_sha="", + created_at=_EPOCH, updated_at=_EPOCH, labels=labels, paths=(), paths_truncated=False, references=(), + references_dropped=0, reviews=(), last_unlabel=None, last_author_comment_at=None, bot_comment=bot, + auto_merge=False, author_record=False, held_use={}, roster=frozenset(), mode="legacy", sweep=True, + event_action=None, + ) + + +def _retract_one( + run: Runner, number: int, pull: dict, bot: BotComment | None, *, authors: str, today: date, +) -> list[str] | None: + """The writes that retract one pull request, or None when it is out of the retraction's scope.""" + if authors == "bots": + target = _login(pull.get("user")) in KNOWN_BOTS + else: + target = bot is not None and not bot.legacy + if not target: + return None + labels = _label_names(pull.get("labels")) + data = {"reason": "retracted", "since": today.isoformat()} + done = bot is not None and bot.data is not None and bot.data.get("reason") == "retracted" + # The marker stays, so the next queue edits this comment instead of posting a second. + comment = None if bot is None or done else f"{render_data_line(data)}\n{MARKER}\n\n{RETRACTED_LINE}" + label = "clear" if roadmap_model.NEEDS_TRIAGE in labels else "keep" + decision = Decision("skip", None, ("retracted",), label, comment, data, False, ()) + return apply(run, _retract_facts(number, pull, bot, labels), decision, pace=PACE_SECONDS) + + +def retract( + run: Runner, numbers: list[int], *, authors: str, today: date, counter: CallCounter | None = None, + mode: str = "legacy", sha: str = "", log=print, +) -> Summary: + """Take the queue label off, and say so, on every pull request the bot queued. + + `authors="all"` reaches every pull request whose bot comment carries the marker. + `authors="bots"` reaches every pull request by a KNOWN_BOTS author, comment or not. + No comment is ever posted. An existing bot comment is edited. + """ + counter = counter or CallCounter() + counted = counter.wrap(run) + summary = Summary(mode=mode, tool_sha=sha, evaluated=len(numbers)) + for index, number in enumerate(numbers): + try: + comments = paginate(counted, f"{_API}/issues/{number}/comments?per_page=100") + pull = _read_pull(counted, number) + done = _retract_one(counted, number, pull, _bot_comment(comments), authors=authors, today=today) + except BudgetStop: + summary.stopped = True + summary.skipped += len(numbers) - index + log(f"::error::The budget ran out before {len(numbers) - index} pull requests were retracted.") + break + except _PER_PULL_ERRORS as exc: + _error(summary, number, exc, log) + continue + if done is None: + summary.unchanged += 1 + summary.queued_total += roadmap_model.NEEDS_TRIAGE in _label_names(pull.get("labels")) + continue + summary.cleared += 1 + summary.writes += len(done) + log(f"#{number} retracted") + summary.calls = counter.calls + return summary + + +def _today() -> date: + return datetime.now(timezone.utc).date() + + +def _report(summary: Summary, lines: list[str]) -> None: + """Print the summary line and append it, with one line per pull request, to the step summary.""" + line = summary.line() + print(line) + path = os.environ.get("GITHUB_STEP_SUMMARY") + if not path: + return + body = [line, ""] + [f"- {text}" for text in lines if not text.startswith("::")] + try: + with open(path, "a", encoding="utf-8") as handle: + handle.write("\n".join(body) + "\n") + except OSError: + print("::warning::The step summary could not be written.") + + +def _collector() -> tuple[list[str], object]: + lines: list[str] = [] + + def log(text: str) -> None: + print(text) + lines.append(text) + + return lines, log + + +def _main_event( + run: Runner, number: int, *, state: str, action: str | None, roster: frozenset[str], today: date, +) -> int: + """One pull request on its own event. Green on anything but a failed auto-merge disable.""" + if state == "mismatched": + print("::notice::PR_INTAKE_TOOL_SHA does not match the armed modules on this ref, so this run is legacy.") + mode = "enforced" if state == "enforced" else "legacy" + counter = CallCounter() + counted = counter.wrap(run) + try: + if action == "synchronize": + # A push changes no fact the gate reads except the paths, which only the + # editorial route reads. The nightly sweep catches the head change. + body = _read_pull(counted, number).get("body") + if not editorial_ticked(body[: closing_choice.MAX_CHARS] if isinstance(body, str) else ""): + print(f"::notice::#{number}: a push without the editorial box changes nothing the gate reads.") + return 0 + listings = load_listings(counted) + decision, done = evaluate_one( + run, number, listings=listings, roster=roster, mode=mode, today=today, sweep=False, + event_action=action, counter=counter, write=True, + ) + except WriteError as exc: + if _is_auto_merge_failure(exc): + print(f"::error::#{number}: WriteError on the auto-merge disable") + return 1 + print(f"::notice::#{number}: WriteError, and the next run retries") + return 0 + except (ListingError, BudgetStop, *_PER_PULL_ERRORS) as exc: + print(f"::notice::#{number}: {type(exc).__name__}, so nothing was written") + return 0 + print(_decision_line(number, decision)) + if done: + print(f"#{number} wrote {','.join(done)}") + return 0 + + +def _main_sweep( + run: Runner, number: int | None, *, state: str, intake: str, roster: frozenset[str], today: date, sha: str, +) -> int: + """Every open pull request, or the one dispatched. Red on any error or an early stop.""" + dry = intake == "dryrun" + mode = "enforced" if dry or state == "enforced" else "legacy" + lines, log = _collector() + counter = CallCounter() + try: + listings = load_listings(counter.wrap(run)) + except (ListingError, BudgetStop) as exc: + print(f"::error::A shared listing failed ({type(exc).__name__}), so no pull request was evaluated.") + _report(Summary(mode=state, tool_sha=sha, calls=counter.calls), lines) + return 1 + summary = run_sweep( + run, None if number is None else [number], mode=mode, write=not dry and mode == "enforced", + listings=listings, roster=roster, today=today, counter=counter, arming_mode=state, sha=sha, log=log, + ) + red = bool(summary.errors) or summary.stopped + if state == "mismatched" and not dry: + print(MISMATCH_ERROR) + red = True + if dry: + print(f"arm with PR_INTAKE_TOOL_SHA={sha}") + _report(summary, lines) + return 1 if red else 0 + + +def _main_retract(run: Runner, number: int | None, *, state: str, authors: str, today: date, sha: str) -> int: + if state == "enforced": + print("::error::Unset PR_INTAKE_ENFORCED before retracting") + return 1 + lines, log = _collector() + counter = CallCounter() + if number is None: + try: + numbers = [int(pull["number"]) for pull in _pulls(paginate(counter.wrap(run), f"{_API}/pulls?state=open&per_page=100"))] + except (RunnerError, BudgetStop) as exc: + print(f"::error::The open pull request listing failed ({type(exc).__name__}), so nothing was retracted.") + _report(Summary(mode=state, tool_sha=sha, calls=counter.calls), lines) + return 1 + else: + numbers = [number] + summary = retract(run, numbers, authors=authors, today=today, counter=counter, mode=state, sha=sha, log=log) + _report(summary, lines) + return 1 if summary.errors or summary.stopped else 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--gh", default=GH, help="the gh binary to run, for tests only") + args = parser.parse_args(argv) + env = os.environ + raw_number = env.get("PR_NUMBER", "") + if raw_number and not _DIGITS.fullmatch(raw_number): + print("::error::PR_NUMBER is not a pull request number.") + return 1 + number = int(raw_number) if raw_number else None + intake = env.get("INTAKE_MODE") or "evaluate" + authors = env.get("INTAKE_AUTHORS") or "all" + event = env.get("EVENT_NAME", "") + if intake not in INTAKE_MODES or authors not in AUTHOR_SCOPES or event not in EVENT_NAMES | SWEEP_NAMES: + print("::error::INTAKE_MODE, INTAKE_AUTHORS, or EVENT_NAME has a value this tool does not know.") + return 1 + if event in EVENT_NAMES and number is None: + print("::error::An event run needs PR_NUMBER.") + return 1 + + sha = tool_sha(REPO_ROOT) + state = arming(env, REPO_ROOT) + # The token goes to the runner's environment and nowhere else. + run = make_runner(args.gh, env.get("GH_TOKEN", "")) + today = _today() + if intake == "retract": + return _main_retract(run, number, state=state, authors=authors, today=today, sha=sha) + try: + roster = roadmap_model.trusted_logins(REPO_ROOT) + except (OSError, ValueError) as exc: + # Without the roster every lead reads as an outsider, so nothing is decided at all. + print(f"::warning::The roster did not parse ({type(exc).__name__}), so nothing was evaluated or written.") + return 0 if event in EVENT_NAMES else 1 + if event in EVENT_NAMES and intake == "evaluate": + return _main_event(run, number, state=state, action=env.get("EVENT_ACTION") or None, roster=roster, today=today) + return _main_sweep(run, number, state=state, intake=intake, roster=roster, today=today, sha=sha) + + +if __name__ == "__main__": + sys.exit(main()) From db89fdef0c2dade1d118900dd5c91b57ce360c05 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 21:12:37 -0600 Subject: [PATCH 11/31] Keep the auto-merge disable outside the call budget, cap Retry-After, and stamp seen with the bot's write time Signed-off-by: rocklambros --- tests/test_pr_intake.py | 118 ++++++++++++++++++++++++++++++++-- tools/pr_intake.py | 137 +++++++++++++++++++++++++++++++--------- 2 files changed, 218 insertions(+), 37 deletions(-) diff --git a/tests/test_pr_intake.py b/tests/test_pr_intake.py index 1abb1b8f..c13ecd8e 100644 --- a/tests/test_pr_intake.py +++ b/tests/test_pr_intake.py @@ -1276,6 +1276,10 @@ def test_apply_falls_back_to_draft_when_auto_merge_survives_the_disable(): from pr_intake import Summary, arming, retract, run_sweep, tool_sha # noqa: E402 SWEEP_TODAY = date(2026, 11, 10) +WRITE_TIME = "2026-11-10T03:00:00Z" +# Where the fake moves `updated_at` on every write, a few seconds after the bot's write time, +# as GitHub does for the bot's own comment, label, and auto-merge writes. +BUMPED = "2026-11-10T03:00:05Z" ENV_KEYS = ( "PR_NUMBER", "INTAKE_MODE", "INTAKE_AUTHORS", "EVENT_NAME", "EVENT_ACTION", "GITHUB_STEP_SUMMARY", "PR_INTAKE_ENFORCED", "PR_INTAKE_TOOL_SHA", "GH_TOKEN", @@ -1313,6 +1317,7 @@ def bot_said(body: str, comment_id: int) -> dict: class FakeGitHub: def __init__(self, pulls, *, accepted=(), rate=5000): + self.clock = BUMPED self.pulls = {pull["number"]: pull for pull in pulls} self.accepted = list(accepted) self.comments: dict[int, list] = {} @@ -1342,6 +1347,7 @@ def __call__(self, args, stdin=None): return 0, json.dumps({"resources": {"core": {"remaining": value}}}), "" if args[0] == "pr": pull = self.pulls[int(args[2])] + pull["updated_at"] = self.clock if args[1] == "merge": pull["auto_merge"] = None else: @@ -1370,12 +1376,14 @@ def _list(self, tail: str) -> list: def _write(self, method: str, tail: str, payload) -> str: parts = tail.split("/") if method == "PATCH": - for comments in self.comments.values(): + for number, comments in self.comments.items(): for comment in comments: if comment["id"] == int(parts[2]): comment["body"] = payload["body"] + self.pulls[number]["updated_at"] = self.clock return OK number = int(parts[1]) + self.pulls[number]["updated_at"] = self.clock if parts[2] == "comments": self.comments.setdefault(number, []).append(bot_said(payload["body"], next(self._ids))) return http({"id": 1}, 201) @@ -1399,6 +1407,7 @@ def bodies(self, number: int) -> list[str]: @pytest.fixture(autouse=True) def _no_pacing(monkeypatch): monkeypatch.setattr(pr_intake, "PACE_SECONDS", 0) + monkeypatch.setattr(pr_intake, "_now", lambda: WRITE_TIME) def sweep(fake, *, mode="enforced", write=True, numbers=None, counter=None, today=SWEEP_TODAY): @@ -1604,7 +1613,9 @@ def test_sweep_reads_only_the_comment_and_the_pull_for_an_unchanged_pull_request first, _ = sweep(fake) assert first.newly_queued == 1 data = parse_data_line(fake.bodies(501)[0]) - assert data["reason"] == "not_held" and data["seen"] == "2026-11-09T01:00:00Z,2026-11-01T00:00:00Z" + assert data["reason"] == "not_held" and data["seen"] == f"{WRITE_TIME},2026-11-01T00:00:00Z" + # The bot's own writes moved updated_at, as they do on GitHub. + assert fake.pulls[501]["updated_at"] == BUMPED fake.calls.clear() second, lines = sweep(fake) @@ -1613,19 +1624,114 @@ def test_sweep_reads_only_the_comment_and_the_pull_for_an_unchanged_pull_request assert second.unchanged == 2 and second.queued_total == 1 and second.writes == 0 and balanced(second) assert "#501 unchanged not_held" in lines + def full_read(fake_) -> bool: + fake_.calls.clear() + sweep(fake_) + return f"{API}/issues/501/timeline?per_page=100" in [args[-1] for args, _ in fake_.calls] + # A change on the referenced issue moves `seen`, so the pull request is read in full. fake.accepted[0]["updated_at"] = "2026-11-09T05:00:00Z" - fake.calls.clear() - sweep(fake) - assert f"{API}/issues/501/timeline?per_page=100" in [args[-1] for args, _ in fake.calls] + assert full_read(fake) + fake.accepted[0]["updated_at"] = "2026-11-01T00:00:00Z" + assert not full_read(fake) + + # Anything on the pull request later than the grace after the bot's write is someone else. + fake.pulls[501]["updated_at"] = "2026-11-10T03:02:00Z" + assert not full_read(fake) + fake.pulls[501]["updated_at"] = "2026-11-10T03:02:01Z" + assert full_read(fake) + fake.pulls[501]["updated_at"] = BUMPED # And a label that no longer matches the reason is read in full and repaired. - fake.accepted[0]["updated_at"] = "2026-11-01T00:00:00Z" fake.pulls[501]["labels"] = [] third, _ = sweep(fake) assert fake.labeled(501) and third.newly_queued == 1 +def test_review_pass_expires_and_the_short_circuit_stops_on_that_day(): + stale_review = review(state="CHANGES_REQUESTED", days=5) + approved = review(state="APPROVED", days=2) + passed = decide(facts(reviews=(approved, stale_review)), TODAY) + assert outcome(passed) == ("pass", "review", ()) + assert passed.data["expires"] == (TODAY - timedelta(days=5) + timedelta(days=30)).isoformat() + + stored = {"reason": "cleared", "route": "review", "seen": f"{WRITE_TIME},none", "expires": "2026-11-20"} + pull = {"updated_at": BUMPED, "labels": []} + assert pr_intake._seen_holds(stored, pull, "none", date(2026, 11, 19)) + assert not pr_intake._seen_holds(stored, pull, "none", date(2026, 11, 20)) + assert not pr_intake._seen_holds({**stored, "expires": "not-a-date"}, pull, "none", date(2026, 11, 1)) + assert not pr_intake._seen_holds({**stored, "seen": "garbage,none"}, pull, "none", date(2026, 11, 1)) + + +def test_requeue_after_retract_restarts_the_clock(): + body = render_data_line({"reason": "retracted", "since": "2026-11-01"}) + "\n" + pr_intake.MARKER + "\n\nRetracted." + retracted = BotComment(id=5, body=body, data=parse_data_line(body), legacy=False) + requeued = decide(facts(bot_comment=retracted), TODAY) + assert requeued.outcome == "queue" and requeued.data["since"] == TODAY.isoformat() + + +def test_dry_sweep_decides_every_pull_request_without_the_short_circuit(monkeypatch, capsys): + fake = FakeGitHub([gh_pull(521)]) + first, _ = sweep(fake) + assert first.newly_queued == 1 + fake.calls.clear() + code, out, _ = run_main(monkeypatch, capsys, fake, EVENT_NAME="workflow_dispatch", INTAKE_MODE="dryrun", + **armed_env()) + assert code == 0 and "#521 queue no_accepted" in out.splitlines() and "#521 unchanged" not in out + assert f"{API}/issues/521/timeline?per_page=100" in [args[-1] for args, _ in fake.calls] + assert fake.writes() == [] + + +def test_auto_merge_write_lands_when_the_budget_runs_out_after_the_label(monkeypatch): + on = {"merge_method": "squash"} + queued = decision(comment=COMMENT, label="set", disable_auto_merge=True) + fake = FakeGitHub([gh_pull(354, auto_merge=on)]) + counter = CallCounter(limit=2) + done = pr_intake._write(fake, facts(auto_merge=True), queued, counter) + assert done == ["comment_post", "label_set", "auto_merge_off"] + assert counter.calls == 2 and fake.pulls[354]["auto_merge"] is None + + fake = FakeGitHub([gh_pull(354, auto_merge=on)]) + with pytest.raises(BudgetStop): + pr_intake._write(fake, facts(auto_merge=True), queued, CallCounter(limit=1)) + assert fake.bodies(354) and not fake.labeled(354) and fake.pulls[354]["auto_merge"] is None + + # Through a sweep, with no reserve, so the budget runs out on the label write itself. + monkeypatch.setattr(pr_intake, "BUDGET_RESERVE", 0) + fake = FakeGitHub([gh_pull(355, auto_merge=on)]) + reads = 1 + 5 + 1 # rate_limit, the five pull request reads, the staleness re-read + summary, _ = sweep(fake, counter=CallCounter(limit=reads + 1)) + assert fake.bodies(355) and not fake.labeled(355) and fake.pulls[355]["auto_merge"] is None + assert summary.stopped and summary.skipped == 1 and balanced(summary) + + +def test_apply_refuses_a_retry_after_over_the_cap(): + refused = raw(429, "Too Many Requests", '{"message":"slow down"}', Retry_After=str(pr_intake.MAX_RETRY_AFTER + 1)) + run = write_runner(refused, OK) + with pytest.raises(WriteError) as error: + apply_with(run, facts(), decision(comment=COMMENT)) + assert str(error.value) == "comment_post retry_after_too_long" + assert len(run.calls) == 1 + + at_cap = raw(429, "Too Many Requests", '{"message":"slow down"}', Retry_After=str(pr_intake.MAX_RETRY_AFTER)) + done, sleeps = apply_with(write_runner(at_cap, http({"id": 1}, 201)), facts(), decision(comment=COMMENT)) + assert done == ["comment_post"] and sleeps == [pr_intake.MAX_RETRY_AFTER] + + +def test_primary_rate_limit_stops_the_write_phase_red(monkeypatch, capsys): + fake = FakeGitHub([gh_pull(531), gh_pull(532), gh_pull(533)]) + primary = raw(403, "Forbidden", '{"message":"API rate limit exceeded for installation ID 1."}') + fake.fail[f"POST {API}/issues/531/comments"] = primary + code, out, _ = run_main(monkeypatch, capsys, fake, EVENT_NAME="schedule", **armed_env()) + assert code == 1 and "::error::#531: WriteError" in out + assert " skipped=2 " in summary_line(out) and " errors=1 " in summary_line(out) + assert not fake.bodies(532) and not fake.bodies(533) + # No stale re-read and no failed write is spent on the pull requests after the stop. + assert [args[-1] for args, _ in fake.calls].count(f"{API}/pulls/532") == 1 + written = [args for args, _ in fake.writes()] + assert len(written) == 1 + + def test_sweep_stops_at_the_budget_reserve_and_is_red(monkeypatch, capsys): pulls = [gh_pull(600 + n) for n in range(12)] fake = FakeGitHub(pulls, rate=[1000, pr_intake.BUDGET_RESERVE - 1]) diff --git a/tools/pr_intake.py b/tools/pr_intake.py index cb50e3cb..3397f9e7 100644 --- a/tools/pr_intake.py +++ b/tools/pr_intake.py @@ -73,7 +73,7 @@ CLEAR_ROUTES = ("roster", "review", "hand", "record", "editorial", "assignment", "help_wanted") DATA_KEYS = frozenset( - {"reason", "route", "issues", "since", "cleared", "head", "cleared_head", "cleared_at", "facts", "claimed", "seen"} + {"reason", "route", "issues", "since", "cleared", "head", "cleared_head", "cleared_at", "facts", "claimed", "seen", "expires"} ) # Linear: one bounded group repeated at most 12 times, each repeat anchored by a literal # `=` and an optional single space, with no quantified group inside another. @@ -535,6 +535,20 @@ def _hash_as_read_next(facts: Facts, cleared_head: str | None, today: date) -> s return facts_hash(replace(facts, bot_comment=comment), today) +def _review_expires(facts: Facts, today: date) -> str | None: + """The day a review pass may stop holding: the earliest qualifying lead review plus 30 days. + + The earliest is the conservative choice. A sweep that re-reads early costs reads, and + one that re-reads late could miss a changes-requested review going stale. + """ + dates = [ + review.submitted_at.date() for review in _lead_reviews(facts) + if review.submitted_at is not None + and (review.state == "APPROVED" or (review.state == "CHANGES_REQUESTED" and _fresh(review, today))) + ] + return (min(dates) + timedelta(days=FRESH_REVIEW_DAYS)).isoformat() if dates else None + + def _data( facts: Facts, today: date, route: str | None, reasons: tuple[str, ...], cleared_head: str | None, cleared_at: str | None, @@ -546,6 +560,10 @@ def _data( data["route"] = route same = stored.get("reason") == "cleared" and stored.get("route") == route data["cleared"] = stored["cleared"] if same and "cleared" in stored else today.isoformat() + if route == "review": + expires = _review_expires(facts, today) + if expires is not None: + data["expires"] = expires else: data["reason"] = reasons[0] issues = sorted({ref.number for ref in _accepted_issues(facts)}) @@ -553,7 +571,8 @@ def _data( data["issues"] = ",".join(str(number) for number in issues[:MAX_DATA_ISSUES]) # The queue clock runs from the first queue and survives re-renders, so the health # issue's day count is the time waited, not the time since the last edit. - queued_before = stored.get("reason") not in (None, "cleared") and "since" in stored + # A retraction ends the queue, so a later queue starts a new clock. + queued_before = stored.get("reason") not in (None, "cleared", "retracted") and "since" in stored data["since"] = stored["since"] if queued_before else today.isoformat() claims = _claims(facts) if claims: @@ -1200,6 +1219,9 @@ def _assemble( _PRIMARY_LIMIT = "API rate limit exceeded" _RETRY_STATUSES = frozenset({403, 429}) _RETRY_AFTER = re.compile(r"[0-9]{1,6}") +# The longest Retry-After honored. A sweep writes to many pull requests, so a longer wait +# would hold the job far past its timeout. The write fails instead and the next run retries. +MAX_RETRY_AFTER = 60 class WriteError(RuntimeError): @@ -1280,6 +1302,8 @@ def _api_write(run: Runner, name: str, args: list[str], stdin: str | None, sleep raise WriteError("rate_limit_primary") wait = headers.get("retry-after", "") if attempt == 0 and status in _RETRY_STATUSES and _RETRY_AFTER.fullmatch(wait): + if int(wait) > MAX_RETRY_AFTER: + raise WriteError(f"{name} retry_after_too_long") sleep(int(wait)) continue raise WriteError(f"{name} returned HTTP {status}") @@ -1317,6 +1341,7 @@ def _api_write(run: Runner, name: str, args: list[str], stdin: str | None, sleep # is printed, never the message, which may carry response text. _PER_PULL_ERRORS = (RunnerError, WriteError, KeyError, TypeError, ValueError) _AUTO_MERGE_WRITES = frozenset({"auto_merge_off", "converted_to_draft"}) +SEEN_GRACE_SECONDS = 120 def _blob_sha(data: bytes) -> str: @@ -1368,23 +1393,22 @@ def line(self) -> str: ) -def seen_value(pull: dict, listings: Listings) -> str | None: - """The pull request's `updated_at` and the newest `updated_at` of the accepted issues it names. +def _issues_seen(pull: dict, listings: Listings) -> str: + """The newest `updated_at` of the accepted issues the pull request names, or `none`. - A sweep compares this with the data line's `seen` and reads nothing more when they - match. A comment, push, review, or label moves the first half. A label, assignment, or - comment on a referenced issue moves the second. + A label, assignment, or comment on a referenced issue moves it. """ - updated = pull.get("updated_at") - if not isinstance(updated, str): - return None title, body = pull.get("title"), pull.get("body") named, _dropped = referenced( title if isinstance(title, str) else "", body[: closing_choice.MAX_CHARS] if isinstance(body, str) else "" ) times = [listings.accepted[number].get("updated_at") for number, _kind in named if number in listings.accepted] - newest = max((when for when in times if isinstance(when, str)), default="none") - return f"{updated},{newest}" + return max((when for when in times if isinstance(when, str)), default="none") + + +def _now() -> str: + """The bot's write time in the data line's form. Tests replace it.""" + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def _with_seen(decision: Decision, seen: str | None) -> Decision: @@ -1395,13 +1419,38 @@ def _with_seen(decision: Decision, seen: str | None) -> Decision: try: line = render_data_line(data) except ValueError: - # A timestamp this line cannot carry. The comment goes out without `seen`, and the - # next sweep reads the pull request in full, which is the safe direction. + # A value this line cannot carry. The comment goes out without `seen`, and the next + # sweep reads the pull request in full, which is the safe direction. return decision rest = decision.comment.split("\n", 1)[1] return replace(decision, data=data, comment=(line + "\n" + rest)[:MAX_COMMENT]) +def _seen_holds(stored: dict[str, str], pull: dict, issues: str, today: date) -> bool: + """Whether nothing the gate reads can have moved since the bot last wrote its comment. + + `seen` is the bot's write time and the referenced issues' newest `updated_at`. The + bot's own label and auto-merge writes land seconds after the comment and move the pull + request's `updated_at`, so a grace of SEEN_GRACE_SECONDS absorbs them. Anything later is + someone else, and the pull request is read in full. A review pass carries `expires`, + the day the review can go stale with no change on the pull request. + """ + written, _, recorded_issues = stored.get("seen", "").partition(",") + if recorded_issues != issues: + return False + try: + written_at = datetime.strptime(written, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) + updated = _when(pull.get("updated_at")) + expires = stored.get("expires") + if expires is not None and today >= date.fromisoformat(expires): + return False + except ValueError: + return False + if updated is None or updated > written_at + timedelta(seconds=SEEN_GRACE_SECONDS): + return False + return _label_agrees(stored.get("reason"), _label_names(pull.get("labels"))) + + def _label_agrees(reason: str | None, labels: frozenset[str]) -> bool: if reason == "cleared": return roadmap_model.NEEDS_TRIAGE not in labels @@ -1417,6 +1466,7 @@ class _Assessment: decision: Decision | None updated_at: object stored_reason: str | None = None + issues: str | None = None def _assess( @@ -1426,17 +1476,17 @@ def _assess( counted = counter.wrap(run) comments = paginate(counted, f"{_API}/issues/{number}/comments?per_page=100") pull = _read_pull(counted, number) - seen = seen_value(pull, listings) - if short_circuit and seen is not None: + issues = _issues_seen(pull, listings) + if short_circuit: bot = _bot_comment(comments) stored = bot.data if bot is not None and bot.data is not None else {} - if stored.get("seen") == seen and _label_agrees(stored.get("reason"), _label_names(pull.get("labels"))): - return _Assessment(number, None, None, pull.get("updated_at"), stored["reason"]) + if _seen_holds(stored, pull, issues, today): + return _Assessment(number, None, None, pull.get("updated_at"), stored["reason"], issues) facts = collect_facts( run, number, listings=listings, roster=roster, mode=mode, sweep=sweep, event_action=event_action, counter=counter, pull=pull, comments=comments, ) - return _Assessment(number, facts, _with_seen(decide(facts, today), seen), pull.get("updated_at")) + return _Assessment(number, facts, decide(facts, today), pull.get("updated_at"), None, issues) def _has_writes(facts: Facts, decision: Decision) -> bool: @@ -1447,16 +1497,31 @@ def _is_auto_merge_failure(exc: WriteError) -> bool: return str(exc).split(" ", 1)[0] in _AUTO_MERGE_WRITES -def _write(run: Runner, facts: Facts, decision: Decision, counter: CallCounter) -> list[str]: - counted = counter.wrap(run) +def _write( + run: Runner, facts: Facts, decision: Decision, counter: CallCounter, issues: str | None = None, +) -> list[str]: + """Stamp `seen` with this write's time, then make the decision's writes. + + The comment and label writes count against the run's budget. The auto-merge write, + its re-read, and the draft fallback do not, and they run even when an earlier write + failed or the budget ran out, because auto-merge left on is the harm the gate exists + to prevent. + """ + if issues is not None: + decision = _with_seen(decision, f"{_now()},{issues}") + merge_off = decision.disable_auto_merge and facts.auto_merge + merge_only = replace(decision, comment=None, label="keep") try: - return apply(counted, facts, decision, pace=PACE_SECONDS) - except WriteError as exc: - # A failed comment or label write stops `apply` before the auto-merge write, and - # auto-merge left on is the harm the gate exists to prevent, so it is still tried. - if decision.disable_auto_merge and facts.auto_merge and not _is_auto_merge_failure(exc): - apply(counted, facts, replace(decision, comment=None, label="keep"), pace=PACE_SECONDS) + done = apply(counter.wrap(run), facts, replace(decision, disable_auto_merge=False), pace=PACE_SECONDS) + except (WriteError, BudgetStop): + if merge_off: + apply(run, facts, merge_only, pace=PACE_SECONDS) raise + if merge_off: + if done: + time.sleep(PACE_SECONDS) + done += apply(run, facts, merge_only, pace=PACE_SECONDS) + return done def evaluate_one( @@ -1471,7 +1536,7 @@ def evaluate_one( facts, decision = assessment.facts, assessment.decision if not write or not _has_writes(facts, decision): return decision, [] - return decision, _write(run, facts, decision, counter) + return decision, _write(run, facts, decision, counter, assessment.issues) def _decision_line(number: int, decision: Decision) -> str: @@ -1553,7 +1618,9 @@ def run_sweep( raise BudgetStop("read budget reached") assessed.append(_assess( run, number, listings=listings, roster=roster, mode=mode, today=today, sweep=True, - event_action=None, counter=counter, short_circuit=mode == "enforced", + # A dry run decides every pull request, so it shows what enforcement would do + # today rather than what a data line recorded. + event_action=None, counter=counter, short_circuit=mode == "enforced" and write, )) except BudgetStop: summary.stopped = True @@ -1566,7 +1633,7 @@ def run_sweep( if mode == "enforced": _batch(assessed) - for item in assessed: + for position, item in enumerate(assessed): if item.decision is None: summary.unchanged += 1 summary.queued_total += item.stored_reason in _QUEUE_REASONS @@ -1585,7 +1652,7 @@ def run_sweep( summary.skipped += 1 log(f"#{item.number} skipped, changed during the sweep") continue - summary.writes += len(_write(run, facts, decision, counter)) + summary.writes += len(_write(run, facts, decision, counter, item.issues)) except BudgetStop: if not summary.stopped: log("::error::The call budget ran out before every write was made.") @@ -1594,6 +1661,14 @@ def run_sweep( continue except _PER_PULL_ERRORS as exc: _error(summary, item.number, exc, log) + if isinstance(exc, WriteError) and str(exc) == "rate_limit_primary": + # The primary limit resets on a window far longer than this job, so + # every later write would fail too. The rest wait for the next run. + rest = len(assessed) - position - 1 + summary.stopped = True + summary.skipped += rest + log(f"::error::The primary rate limit stopped the writes before {rest} pull requests.") + break continue _count(summary, facts, decision) summary.calls = counter.calls From 1a40baea75a5251993338e55dc671b7d3715baa2 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 21:21:29 -0600 Subject: [PATCH 12/31] Run the intake gate from one workflow step on a protected base ref Signed-off-by: rocklambros --- .github/workflows/pr-intake.yml | 164 ++++++------ tests/test_pr_intake_workflow.py | 424 ++++++++++++++++++++++++------- 2 files changed, 398 insertions(+), 190 deletions(-) diff --git a/.github/workflows/pr-intake.yml b/.github/workflows/pr-intake.yml index be44a769..dd8a8a57 100644 --- a/.github/workflows/pr-intake.yml +++ b/.github/workflows/pr-intake.yml @@ -1,109 +1,89 @@ -# Tells a contributor why their pull request is waiting, and labels it so the queue is -# visible. +# Decides whether each pull request has an accepted issue behind it, queues the ones that +# do not, and says why once. The logic lives in tools/pr_intake.py, which this job runs as +# one step. Version 2.0. Owner: ACS project leads. # # pull_request_target is used deliberately: GITHUB_TOKEN is read-only on a pull_request -# event from a fork, and every external contribution is a fork. That trigger runs with -# repository write scope against the base branch, so this workflow checks out no code, -# runs no contributor-supplied script, and reads the pull request body from the event -# payload as data. +# event from a fork, and every external contribution is a fork. That trigger runs with the +# base repository's token, so the job checks out the pull request's base ref, never the +# head. main, integration, and release/* are ruleset-protected, and the job's if: admits +# no other base, so only code a lead approved runs beside the token. The tool reads +# titles, bodies, and comments through the API as data. Its env carries the number, the +# event name and action, the dispatch inputs, and two repository variables, nothing else. +# +# The nightly sweep runs at 03:10 UTC so the 04:10 roadmap sweep can report the same +# night's pass. That ordering is best effort: roadmap-refresh.yml records scheduler start +# delays of up to two hours on this repository. +# +# PR_INTAKE_ENFORCED and PR_INTAKE_TOOL_SHA arm enforcement. Unsetting PR_INTAKE_ENFORCED +# is the kill switch, and the tool then runs only the legacy pass. +# tests/test_pr_intake_workflow.py pins this file. name: PR intake on: - # zizmor flags every pull_request_target. The paragraph above is why this one is safe. + # zizmor flags every pull_request_target. This one checks out a protected base ref, + # never the pull request head, so no code from the pull request runs with the token. pull_request_target: # zizmor: ignore[dangerous-triggers] - types: [opened, edited, reopened] + types: [opened, edited, reopened, ready_for_review, synchronize, unlabeled, auto_merge_enabled] + pull_request_review: + types: [submitted, dismissed] + schedule: + - cron: "10 3 * * *" + workflow_dispatch: + inputs: + pr_number: + description: "One pull request to evaluate or retract. Empty means every open pull request." + type: string + required: false + mode: + description: "evaluate applies the gate. dryrun prints every decision and writes nothing. retract undoes the queue." + type: choice + options: [evaluate, dryrun, retract] + default: evaluate + authors: + description: "Whose pull requests a retraction reaches. bots means only known bot authors." + type: choice + options: [all, bots] + default: all permissions: {} concurrency: - group: pr-intake-${{ github.event.pull_request.number }} - cancel-in-progress: true + group: ${{ github.event.pull_request.number && format('pr-intake-{0}', github.event.pull_request.number) || inputs.pr_number && format('pr-intake-{0}', inputs.pr_number) || 'pr-intake-sweep' }} + # A newer event re-reads every fact, so an older event run is safe to cancel. A sweep + # or a dispatch is never cancelled, because it is attended and reports a summary. + cancel-in-progress: ${{ github.event_name == 'pull_request_target' }} jobs: - triage: + intake: + name: PR intake + # Pull request events run only against a protected base, and an edit runs only when + # it changed the body, the title, or the base. + if: >- + github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || + ((github.event.pull_request.base.ref == 'main' || github.event.pull_request.base.ref == 'integration' || + startsWith(github.event.pull_request.base.ref, 'release/')) && + (github.event.action != 'edited' || github.event.changes.body || github.event.changes.title || + github.event.changes.base)) runs-on: ubuntu-latest + timeout-minutes: 20 permissions: - pull-requests: write - issues: read + contents: read # check out the base ref the tool runs from + pull-requests: write # comment, set and clear the queue label, and turn auto-merge off + issues: read # list accepted and milestoned issues and read claim comments steps: - - name: Check whether a referenced issue is accepted - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_BODY: ${{ github.event.pull_request.body }} - run: | - set -euo pipefail - # Issue numbers are extracted from the body text, which is untrusted input. It - # is matched against a digit pattern and never evaluated. - NUMBERS="$(printf '%s' "${PR_BODY:-}" | grep -oE '#[0-9]+' | tr -d '#' | sort -u || true)" - if printf '%s' "${PR_BODY:-}" | grep -qiE '^\s*-\s*\[x\]\s*This is an editorial correction'; then - echo "Declared editorial. No issue required." - exit 0 - fi - # The `edited` trigger fires every time anyone touches the body, so without - # this the queue comment gets reposted on every edit. The Actions bot reports - # as `github-actions` in some payloads and `github-actions[bot]` in others, so - # the check is a prefix match rather than an exact one. - if gh pr view "$PR_NUMBER" --repo "$REPO" --json comments \ - --jq '[.comments[] | select(.author.login | startswith("github-actions")) | select(.body | contains("" - if gh pr view "$PR_NUMBER" --repo "$REPO" --json comments \ - --jq '.comments[] | select(.author.login | startswith("github-actions")) | .body' \ - | grep -qF "$MARK"; then - exit 0 - fi - gh pr comment "$PR_NUMBER" --repo "$REPO" --body "$MARK - This pull request's description says it closes$HITS, which is on the project roadmap. When it merges, GitHub closes that issue and the roadmap counts it as delivered. Please confirm the change delivers the whole issue before merging." + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + INTAKE_MODE: ${{ inputs.mode || 'evaluate' }} + INTAKE_AUTHORS: ${{ inputs.authors || 'all' }} + EVENT_NAME: ${{ github.event_name }} + EVENT_ACTION: ${{ github.event.action }} + PR_INTAKE_ENFORCED: ${{ vars.PR_INTAKE_ENFORCED }} + PR_INTAKE_TOOL_SHA: ${{ vars.PR_INTAKE_TOOL_SHA }} + run: python3 -I -S tools/pr_intake.py diff --git a/tests/test_pr_intake_workflow.py b/tests/test_pr_intake_workflow.py index 19dfafe8..b52944cc 100644 --- a/tests/test_pr_intake_workflow.py +++ b/tests/test_pr_intake_workflow.py @@ -1,125 +1,353 @@ -"""Runs pr-intake's shell steps against a fake gh, as the workflow would. +"""Pins pr-intake.yml and runs its one step end to end against a fake gh. -pr-intake checks out no code by design, so its logic lives in the YAML. These tests lift -each step's script out of the YAML and run it, so the behavior is tested, not only read. +The workflow runs under pull_request_target with a write token, so every key that bounds +what runs beside that token is pinned here: the triggers, the base ref guard, the +checkout, the permissions, the concurrency group, the env, and the run line. + +The step is then run as the runner would run it. `run_tool` builds the environment from +the step's own `env` block, substituting each `${{ }}` value from a mapping the test +controls, and executes the tool with `--gh` pointed at a fake. The fake serves the +recorded fixtures under tests/fixtures/pr_intake, records every call, and refuses any argv +outside gh_runner's allowlist, as `runner_from_fixtures` does in-process. """ from __future__ import annotations import json -import os +import re +import shlex import subprocess +import sys from pathlib import Path import yaml -WORKFLOW = Path(__file__).resolve().parents[1] / ".github" / "workflows" / "pr-intake.yml" +from test_pr_intake import listing_mapping, pull_mapping, slurp, with_body + +REPO_ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = REPO_ROOT / ".github" / "workflows" / "pr-intake.yml" +ROADMAP_SYNC = REPO_ROOT / ".github" / "workflows" / "roadmap-sync.yml" +TOOLS = REPO_ROOT / "tools" +API = "repos/GenAI-Security-Project/agent-control-standard" +ACCEPTED_LISTING = f"{API}/issues?labels=status%3Aaccepted&state=all&per_page=100" +OPEN_PULLS = f"{API}/pulls?state=open&per_page=100" +RATE_LIMIT = json.dumps({"resources": {"core": {"remaining": 5000}}}) + +IF = ( + "github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || " + "((github.event.pull_request.base.ref == 'main' || github.event.pull_request.base.ref == 'integration' || " + "startsWith(github.event.pull_request.base.ref, 'release/')) && " + "(github.event.action != 'edited' || github.event.changes.body || github.event.changes.title || " + "github.event.changes.base))" +) +STEP_ENV = { + "GH_TOKEN": "${{ github.token }}", + "PR_NUMBER": "${{ github.event.pull_request.number || inputs.pr_number }}", + "INTAKE_MODE": "${{ inputs.mode || 'evaluate' }}", + "INTAKE_AUTHORS": "${{ inputs.authors || 'all' }}", + "EVENT_NAME": "${{ github.event_name }}", + "EVENT_ACTION": "${{ github.event.action }}", + "PR_INTAKE_ENFORCED": "${{ vars.PR_INTAKE_ENFORCED }}", + "PR_INTAKE_TOOL_SHA": "${{ vars.PR_INTAKE_TOOL_SHA }}", +} + +# The fake gh. A shell wrapper starts it, because gh_runner hands the process an +# environment of six keys, so the fake's own paths are written into it, not passed in env. +FAKE_GH = r'''import json +import sys + +sys.path.insert(0, TOOLS) +import gh_runner -FAKE_GH = r'''#!/usr/bin/env python3 -import json, os, sys -log = os.environ["GH_LOG"] -with open(log, "a") as handle: - handle.write(json.dumps(sys.argv[1:]) + "\n") args = sys.argv[1:] -state = json.loads(os.environ["GH_STATE"]) -if args[:2] == ["issue", "list"]: - if state.get("fail_list"): - sys.exit(1) - print("\n".join(str(n) for n in state["accepted"])) -elif args[:1] == ["api"] and "milestone=*" in " ".join(args): - print("\n".join(str(n) for n in state["milestoned"])) -elif args[:2] == ["pr", "view"]: - jq = args[args.index("--jq") + 1] if "--jq" in args else "" - if "length" in jq: - print(state.get("bot_comment_count", 0)) - else: - print("\n".join(state.get("comments", []))) +stdin = sys.stdin.read() if "--input" in args else None +entry = {"args": args, "stdin": stdin} +try: + gh_runner._check_allowlist(args) +except gh_runner.RunnerError: + entry["refused"] = True +if args[0] == "pr": + key, write = f"pr {args[1]}", True +elif "-X" in args: + key, write = f"{args[3]} {args[4]}", True +else: + key, write = args[-1], False +with open(STATE, encoding="utf-8") as handle: + serve = json.load(handle) +if entry.get("refused"): + code, out, err = 2, "", "outside the allowlist\n" +elif key in serve: + value = serve[key] + code, out, err = value if isinstance(value, list) else (0, value, "") +elif args[0] == "pr": + code, out, err = 0, "", "" +elif write: + created = args[3] == "POST" and args[4].endswith("/comments") + status = "201 Created" if created else "200 OK" + code, out, err = 0, "HTTP/2.0 " + status + "\r\n\r\n" + ('{"id": 1}' if created else "{}"), "" +else: + entry["unexpected"] = True + code, out, err = 1, "", "no fixture for this read\n" +with open(LOG, "a", encoding="utf-8") as handle: + handle.write(json.dumps(entry) + "\n") +sys.stdout.write(out) +sys.stderr.write(err) +sys.exit(code) ''' -def step(name: str) -> str: - steps = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))["jobs"]["triage"]["steps"] - return next(s for s in steps if s["name"] == name)["run"] +def load() -> dict: + return yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + + +def job() -> dict: + return load()["jobs"]["intake"] + +def tool_step() -> dict: + (step,) = [step for step in job()["steps"] if "run" in step] + return step -def run_step(tmp_path, name, body, state): - bin_dir = tmp_path / "bin" - bin_dir.mkdir(exist_ok=True) - gh = bin_dir / "gh" - gh.write_text(FAKE_GH) - gh.chmod(0o755) - log = tmp_path / "gh.log" - env = { - "PATH": f"{bin_dir}:/usr/bin:/bin", "GH_LOG": str(log), "GH_STATE": json.dumps(state), - "REPO": "o/n", "PR_NUMBER": "7", "PR_BODY": body, "GH_TOKEN": "x", - "GITHUB_OUTPUT": str(tmp_path / "out"), + +def event(number: int, action: str = "opened", name: str = "pull_request_target") -> dict: + """The expression values a pull request event renders into the step's env.""" + return { + "github.token": "x", + "github.event.pull_request.number || inputs.pr_number": str(number), + "inputs.mode || 'evaluate'": "evaluate", + "inputs.authors || 'all'": "all", + "github.event_name": name, + "github.event.action": action, + "vars.PR_INTAKE_ENFORCED": "", + "vars.PR_INTAKE_TOOL_SHA": "", } - done = subprocess.run(["bash", "-e", "-c", step(name)], env=env, capture_output=True, text=True, timeout=60) - calls = [json.loads(line) for line in log.read_text().splitlines()] if log.exists() else [] - return done, calls -def test_accepted_lookup_is_one_call_whatever_the_body(tmp_path): - body = " ".join(f"#{n}" for n in range(1, 400)) - done, calls = run_step(tmp_path, "Check whether a referenced issue is accepted", body, {"accepted": [399], "milestoned": []}) - assert done.returncode == 0, done.stderr - assert sum(1 for c in calls if c[:2] == ["issue", "list"]) == 1 - assert not any(c[:2] == ["issue", "view"] for c in calls) - - -def test_closing_keyword_on_a_roadmap_issue_comments_once(tmp_path): - name = "Flag closing keywords on roadmap issues" - done, calls = run_step(tmp_path, name, "Fixes typo. Closes: #132 and resolves #9", {"accepted": [], "milestoned": [132]}) - assert done.returncode == 0, done.stderr - comments = [c for c in calls if c[:2] == ["pr", "comment"]] - assert len(comments) == 1 - text = comments[0][comments[0].index("--body") + 1] - assert "" in text and "#132" in text and "#9" not in text - assert "Part of" not in text - - -def test_url_and_qualified_forms_are_caught(tmp_path): - name = "Flag closing keywords on roadmap issues" - body = "Resolves https://github.com/GenAI-Security-Project/agent-control-standard/issues/132" - _done, calls = run_step(tmp_path, name, body, {"accepted": [], "milestoned": [132]}) - assert any(c[:2] == ["pr", "comment"] for c in calls) - - -def test_no_comment_when_already_flagged_or_not_on_roadmap(tmp_path): - name = "Flag closing keywords on roadmap issues" - state = {"accepted": [], "milestoned": [132], "comments": [""]} - _done, calls = run_step(tmp_path, name, "Closes #132", state) - assert not any(c[:2] == ["pr", "comment"] for c in calls) - other = tmp_path / "b" - other.mkdir() - _done, calls = run_step(other, name, "Closes #5. disclose #132", {"accepted": [], "milestoned": [132]}) - assert not any(c[:2] == ["pr", "comment"] for c in calls) - - -def test_keyword_comment_does_not_suppress_the_queue_comment(tmp_path): - # One bot comment exists, but it is the keyword comment, so the length check reads 0. - done, calls = run_step( - tmp_path, "Check whether a referenced issue is accepted", "Closes #132", - {"accepted": [], "milestoned": [132], "bot_comment_count": 0}, - ) - assert done.returncode == 0, done.stderr - assert any(c[:2] == ["pr", "comment"] for c in calls) +def nightly() -> dict: + """The expression values the schedule renders. Every pull request field is empty.""" + return {**event(0), "github.event.pull_request.number || inputs.pr_number": "", "github.event_name": "schedule", + "github.event.action": ""} + + +def step_env(values: dict) -> dict: + """The step's env block with each `${{ }}` value replaced from `values`. + An expression `values` does not name fails the test, so a renamed expression in the + workflow cannot pass by rendering empty. + """ + rendered = {} + for key, value in tool_step()["env"].items(): + match = re.fullmatch(r"\$\{\{ (.+) \}\}", value) + rendered[key] = values[match.group(1)] if match else value + return rendered -def test_no_step_interpolates_into_run(): - steps = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))["jobs"]["triage"]["steps"] - assert all("${{" not in s.get("run", "") for s in steps) +def run_tool(tmp_path: Path, env: dict, state: dict): + """Run the step's tool with `--gh` pointed at a fake that serves `state`. -def test_failed_accepted_listing_queues_the_pull_request_and_exits_zero(tmp_path): - done, calls = run_step( - tmp_path, "Check whether a referenced issue is accepted", "Closes #132", - {"accepted": [132], "milestoned": [], "fail_list": True}, + `state` maps a read's path, or a write's `METHOD path` or `pr `, to stdout + or a `[code, stdout, stderr]` list. A write it does not name succeeds. A read it does + not name fails the call, and the test, so every read the tool makes is accounted for. + """ + state_file, log = tmp_path / "state.json", tmp_path / "gh.log" + state_file.write_text(json.dumps(state), encoding="utf-8") + script = tmp_path / "fake_gh.py" + script.write_text(f"TOOLS = {str(TOOLS)!r}\nSTATE = {str(state_file)!r}\nLOG = {str(log)!r}\n" + FAKE_GH) + fake = tmp_path / "gh" + fake.write_text(f"#!/bin/sh\nexec {shlex.quote(sys.executable)} -I -S {shlex.quote(str(script))} \"$@\"\n") + fake.chmod(0o755) + summary = tmp_path / "summary.md" + environ = {**step_env(env), "GITHUB_STEP_SUMMARY": str(summary)} + done = subprocess.run( + [sys.executable, "-I", "-S", "tools/pr_intake.py", "--gh", str(fake)], + cwd=REPO_ROOT, env=environ, capture_output=True, text=True, timeout=300, ) - assert done.returncode == 0, done.stderr - assert any(c[:2] == ["pr", "comment"] for c in calls) + calls = [json.loads(line) for line in log.read_text().splitlines()] if log.exists() else [] + assert not [call["args"] for call in calls if call.get("refused")], "an argv outside the allowlist reached gh" + assert not [call["args"][-1] for call in calls if call.get("unexpected")], "a read had no fixture" + return done, calls, summary.read_text() if summary.exists() else "" + + +def writes(calls) -> list[list[str]]: + return [call["args"] for call in calls if call["args"][0] == "pr" or "-X" in call["args"]] + + +def stub_pull(number: int) -> dict: + return { + "number": number, "state": "open", "draft": False, "title": "Add a thing", "body": "b", + "user": {"login": f"outsider-{number}", "type": "User"}, "base": {"ref": "integration"}, + "head": {"sha": f"{number:040d}"}, "created_at": "2026-10-20T12:00:00Z", + "updated_at": "2026-10-21T01:00:00Z", "changed_files": 1, "labels": [], "auto_merge": None, + } + + +def stub_reads(pull: dict) -> dict: + return pull_mapping(pull["number"], pull=pull, files=[[{"filename": "tools/x.py"}]], reviews=[], comments=[], + timeline=[]) + + +def sweep_state(pulls: list[dict]) -> dict: + # The open listing arrives as two pages, so the sweep sees all of them only if it + # flattens what --paginate --slurp returns. gh pr list stopped at 30 by default. + state = {**listing_mapping(open_pulls=[]), OPEN_PULLS: slurp(pulls[:30], pulls[30:]), "rate_limit": RATE_LIMIT} + for pull in pulls: + state.update(stub_reads(pull)) + return state + + +def test_triggers_and_cron(): + on = load()[True] # PyYAML reads the key `on` as the boolean True. + assert on["pull_request_target"] == {"types": [ + "opened", "edited", "reopened", "ready_for_review", "synchronize", "unlabeled", "auto_merge_enabled", + ]} + assert on["pull_request_review"] == {"types": ["submitted", "dismissed"]} + assert on["schedule"] == [{"cron": "10 3 * * *"}] + inputs = on["workflow_dispatch"]["inputs"] + assert set(inputs) == {"pr_number", "mode", "authors"} + assert inputs["pr_number"]["type"] == "string" and inputs["pr_number"]["required"] is False + assert inputs["mode"]["type"] == "choice" and inputs["mode"]["options"] == ["evaluate", "dryrun", "retract"] + assert inputs["mode"]["default"] == "evaluate" + assert inputs["authors"]["type"] == "choice" and inputs["authors"]["options"] == ["all", "bots"] + assert inputs["authors"]["default"] == "all" + + +def test_cron_runs_before_the_roadmap_sweep(): + def minutes(cron: str) -> int: + minute, hour, *_ = cron.split() + return int(hour) * 60 + int(minute) + + ours = minutes(load()[True]["schedule"][0]["cron"]) + roadmap = minutes(yaml.safe_load(ROADMAP_SYNC.read_text(encoding="utf-8"))[True]["schedule"][0]["cron"]) + assert ours < roadmap + # Best effort only, which the header says, because the scheduler can start a run late. + assert "best effort" in WORKFLOW.read_text(encoding="utf-8") -def test_keyword_step_runs_after_an_earlier_failure_but_skips_body_free_edits(): - steps = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))["jobs"]["triage"]["steps"] - flag = next(s for s in steps if s["name"] == "Flag closing keywords on roadmap issues") - assert flag["if"] == "always() && (github.event.action != 'edited' || github.event.changes.body)" +def test_checkout_is_the_base_ref_never_the_head(): + checkout = job()["steps"][0] + assert checkout == { + "uses": "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", + "with": {"persist-credentials": False, "ref": "${{ github.event.pull_request.base.ref || 'integration' }}"}, + } + text = WORKFLOW.read_text(encoding="utf-8") + for head in ("head.sha", "head.ref", "head.repo", "refs/pull", "merge_commit_sha"): + assert head not in text + assert [step.get("uses") for step in job()["steps"]].count(checkout["uses"]) == 1 + + +def test_base_ref_guard_admits_only_protected_branches(): + assert job()["if"] == IF + bases = re.findall(r"github\.event\.pull_request\.base\.ref == '([^']+)'", IF) + assert bases == ["main", "integration"] + assert re.findall(r"startsWith\(github\.event\.pull_request\.base\.ref, '([^']+)'\)", IF) == ["release/"] + + +def test_permissions_are_exactly_these(): + assert load()["permissions"] == {} + assert job()["permissions"] == {"contents": "read", "pull-requests": "write", "issues": "read"} + lines = WORKFLOW.read_text(encoding="utf-8").splitlines() + for name in ("contents: read", "pull-requests: write", "issues: read"): + (line,) = [line for line in lines if line.strip().startswith(name)] + assert "# " in line, f"{name} carries no comment saying why" + + +def test_job_has_a_twenty_minute_timeout(): + assert job()["timeout-minutes"] == 20 + assert job()["name"] == "PR intake" + assert list(load()["jobs"]) == ["intake"] + + +def test_concurrency_expression(): + assert load()["concurrency"] == { + "group": ( + "${{ github.event.pull_request.number && format('pr-intake-{0}', github.event.pull_request.number)" + " || inputs.pr_number && format('pr-intake-{0}', inputs.pr_number) || 'pr-intake-sweep' }}" + ), + "cancel-in-progress": "${{ github.event_name == 'pull_request_target' }}", + } + + +def test_no_step_interpolates_into_run_in_any_job(): + for each in load()["jobs"].values(): + for step in each["steps"]: + assert "${{" not in step.get("run", "") + + +def test_edited_guard_covers_body_title_and_base(): + guard = job()["if"] + assert "(github.event.action != 'edited' || github.event.changes.body || github.event.changes.title || " \ + "github.event.changes.base)" in guard + + +def test_step_env_is_exactly_this(): + assert tool_step()["env"] == STEP_ENV + # The token reaches the tool's step and no other. + for step in job()["steps"]: + if "run" not in step: + assert "github.token" not in repr(step) + + +def test_run_line_is_exactly_this(): + assert tool_step()["run"] == "python3 -I -S tools/pr_intake.py" + assert tool_step()["name"] == "Run the intake gate" + assert len(job()["steps"]) == 2 + + +def test_run_tool_against_fixtures_queues_and_comments(tmp_path): + state = {**listing_mapping(), **pull_mapping(229, pull=with_body("Adds a thing."), comments=[])} + done, calls, _ = run_tool(tmp_path, event(229), state) + assert done.returncode == 0, done.stdout + done.stderr + made = writes(calls) + assert [args[3:5] for args in made] == [ + ["POST", f"{API}/issues/229/comments"], ["POST", f"{API}/issues/229/labels"], + ] + bodies = [json.loads(call["stdin"]) for call in calls if call["args"] in made] + assert bodies[0]["body"].startswith("Thanks for this. It is queued rather than ignored.") + assert bodies[1] == {"labels": ["status:needs-triage"]} + assert "#229 wrote comment_post,label_set" in done.stdout.splitlines() + + +def test_sweep_pages_thirty_five_stubbed_pull_requests(tmp_path): + pulls = [stub_pull(500 + n) for n in range(35)] + done, calls, summary = run_tool(tmp_path, nightly(), sweep_state(pulls)) + assert done.returncode == 0, done.stdout + done.stderr + assert [call["args"] for call in calls if call["args"][-1] == OPEN_PULLS] == [ + ["api", "--paginate", "--slurp", OPEN_PULLS], + ] + read = {call["args"][-1] for call in calls} + assert all(f"{API}/pulls/{pull['number']}" in read for pull in pulls) + assert " evaluated=35 " in summary.splitlines()[0] and " errors=0 " in summary.splitlines()[0] + assert writes(calls) == [] + + +def test_one_failed_evaluation_makes_the_sweep_red_with_its_error_line(tmp_path): + pulls = [stub_pull(600 + n) for n in range(3)] + state = sweep_state(pulls) + state[f"{API}/pulls/601/files?per_page=100"] = [1, "", "HTTP 502: Bad Gateway\n"] + done, calls, summary = run_tool(tmp_path, nightly(), state) + assert done.returncode == 1 + lines = done.stdout.splitlines() + assert "::error::#601: RunnerError" in lines + assert [line for line in lines if line.startswith("::error::")] == ["::error::#601: RunnerError"] + (line,) = [line for line in lines if line.startswith("mode=")] + assert " evaluated=3 " in line and " errors=1 " in line + assert summary.splitlines()[0] == line + assert "Bad Gateway" not in done.stdout + done.stderr + assert writes(calls) == [] + + +def test_event_run_read_failure_exits_zero_with_no_write(tmp_path): + state = {**listing_mapping(), **pull_mapping(229, pull=with_body("Adds a thing."), comments=[])} + state[f"{API}/pulls/229/reviews?per_page=100"] = [1, "", "HTTP 502: Bad Gateway\n"] + done, calls, _ = run_tool(tmp_path, event(229), state) + assert done.returncode == 0, done.stdout + done.stderr + assert "::notice::#229: RunnerError, so nothing was written" in done.stdout.splitlines() + assert writes(calls) == [] + + +def test_accepted_lookup_is_one_call_whatever_the_body(tmp_path): + body = " ".join(f"#{n}" for n in range(1, 400)) + state = {**listing_mapping(), **pull_mapping(229, pull=with_body(body), comments=[])} + done, calls, _ = run_tool(tmp_path, event(229), state) + assert done.returncode == 0, done.stdout + done.stderr + assert sum(1 for call in calls if call["args"][-1] == ACCEPTED_LISTING) == 1 + assert not any(re.fullmatch(rf"{API}/issues/[0-9]+", call["args"][-1]) for call in calls) From 6c1d2987d8b1c8a367c826c8cfdd2217a837bb2b Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 21:29:15 -0600 Subject: [PATCH 13/31] List the intake queue on the health issue, numbers and reasons only Signed-off-by: rocklambros --- .github/workflows/roadmap-sync.yml | 6 + tests/test_roadmap_sync_health.py | 224 +++++++++++++++++++++++ tests/test_roadmap_sync_workflow.py | 5 +- tools/roadmap_sync.py | 266 +++++++++++++++++++++++++++- 4 files changed, 496 insertions(+), 5 deletions(-) diff --git a/.github/workflows/roadmap-sync.yml b/.github/workflows/roadmap-sync.yml index d62566e1..c42065ab 100644 --- a/.github/workflows/roadmap-sync.yml +++ b/.github/workflows/roadmap-sync.yml @@ -64,6 +64,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 permissions: + # Lists the intake sweep's runs for the health issue's queued section. + actions: read contents: read issues: write pull-requests: read @@ -84,6 +86,7 @@ jobs: ROADMAP_REFRESH_ENABLED: ${{ vars.ROADMAP_REFRESH_ENABLED }} ROADMAP_SYNC_ENABLED: ${{ vars.ROADMAP_SYNC_ENABLED }} ROADMAP_HEALTH_ISSUE: ${{ vars.ROADMAP_HEALTH_ISSUE }} + PR_INTAKE_BASELINES: ${{ vars.PR_INTAKE_BASELINES }} run: python3 -I -S tools/roadmap_sync.py sweep --apply dryrun: @@ -91,6 +94,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 permissions: + # Lists the intake sweep's runs for the health issue's queued section. + actions: read contents: read issues: read pull-requests: read @@ -109,4 +114,5 @@ jobs: ROADMAP_REFRESH_ENABLED: ${{ vars.ROADMAP_REFRESH_ENABLED }} ROADMAP_SYNC_ENABLED: ${{ vars.ROADMAP_SYNC_ENABLED }} ROADMAP_HEALTH_ISSUE: ${{ vars.ROADMAP_HEALTH_ISSUE }} + PR_INTAKE_BASELINES: ${{ vars.PR_INTAKE_BASELINES }} run: python3 -I -S tools/roadmap_sync.py dryrun --issue "$ISSUE_NUMBER" diff --git a/tests/test_roadmap_sync_health.py b/tests/test_roadmap_sync_health.py index fe1df45c..0edfbaba 100644 --- a/tests/test_roadmap_sync_health.py +++ b/tests/test_roadmap_sync_health.py @@ -68,6 +68,21 @@ def issue(number, labels=(), state="open", milestone=1, reason=None, title="SECR "unmilestoned_open": [issue(60, ["scope:in-focus"], milestone=None), issue(61, ["status:accepted"], milestone=None)], "facts": {10: landed("rocklambros"), 30: landed("outsider"), 41: landed("rocklambros"), 50: {"closedBy": "outsider"}}, "bot_accepted": [[20, "rocklambros"]], + # Created dates give 42, 22, 7, and 2 days on 2026-11-01. Pull request 104 carries the + # label and no data line. + "queued_pulls": [ + [101, "no_accepted", "2026-09-21", "62:2026-10-02", "2026-09-20T09:00:00Z"], + [102, "awaiting_rereview", "2026-10-25", "", "2026-10-25T09:00:00Z"], + [103, "sent_back", "2026-10-10", "", "2026-10-10T09:00:00Z"], + [104, None, None, "", "2026-10-30T09:00:00Z"], + ], + "intake_claims": [[62, 30, 101]], + "intake_sweep": ["success", 555, "2026-11-01T03:00:00Z"], + "intake_recent": [ + [201, False, None, "2026-10-20T00:00:00Z"], + [202, True, "hand", "2026-10-21T00:00:00Z"], + [203, False, "review", "2026-08-01T00:00:00Z"], + ], } SWITCHES = {"ROADMAP_RENDER_ENABLED": "true", "ROADMAP_REFRESH_ENABLED": "yes", "ROADMAP_SYNC_ENABLED": "true"} @@ -104,6 +119,16 @@ def test_sections(): ["ROADMAP_SYNC_ENABLED", "true", False], ] assert r["closed_nothing_done"] == [5] + assert r["queued_pulls"] == [ + [101, "no_accepted", 42, "62:2026-10-02"], [103, "sent_back", 22, ""], [102, "awaiting_rereview", 7, ""], + ] + assert r["queued_unrecorded"] == [104] + assert r["queued_meta"] == ["success", 555, "2026-11-01T03:00:00Z"] + assert r["queued_unreadable"] == 0 + assert r["intake_counts"] == { + "median_age_days": 22, "hand_cleared_60d": 1, "non_roster_merges_60d": 1, + "non_roster_merges_prior_60d": 1, "claims_waiting": 1, + } def test_render_never_leaks_titles_or_mentions(): @@ -320,3 +345,202 @@ def test_switches_heading_says_every_value_is_listed(): import roadmap_sync note = dict((key, note) for key, _title, note in roadmap_sync.SECTION_TITLES)["switches"] assert note == "The current value of each roadmap variable, flagged when it is neither true nor false." + + +# The queued pull requests section. The intake gate's state is read from the bot's data +# line, and the section renders numbers, reason codes, dates, and days only. + +def intake_comment(line, login="github-actions[bot]"): + return {"user": {"login": login}, "body": f"{line}\n\nQueued text that must never render."} + + +def labeled_pull(number, author="outsider", created="2026-10-01T00:00:00Z"): + return { + "number": number, "state": "open", "pull_request": {}, "title": "SECRET TITLE", "user": {"login": author}, + "labels": [{"name": "status:needs-triage"}], "created_at": created, + } + + +class IntakeGitHub: + def __init__(self, labeled=(), comments=None, closed=(), runs=None): + self.labeled = list(labeled) + self.comments = comments or {} + self.closed = list(closed) + self.runs = runs if runs is not None else {"workflow_runs": []} + self.paths = [] + + def paginate(self, path): + self.paths.append(("paginate", path)) + assert "actions/" not in path, "the runs path returns an object, never a list of pages" + if "/comments" in path: + return self.comments.get(int(path.split("/issues/")[1].split("/")[0]), []) + if "/pulls?" in path: + return self.closed + if "/issues?" in path: + return self.labeled + raise AssertionError(path) + + def get(self, path): + self.paths.append(("get", path)) + if path.endswith("actions/workflows/pr-intake.yml/runs?event=schedule&status=completed&per_page=1"): + return self.runs + raise AssertionError(path) + + +def intake_body(gh, today=date(2026, 11, 1), trusted=TRUSTED, failed=()): + from roadmap_sync import intake_snapshot + snapshot = dict(SNAPSHOT, **intake_snapshot(gh, today, trusted)) + r = build_report(snapshot, TRUSTED, LEADS, {"Spec"}, today, SWITCHES) + return r, render_health(r, "ok", "t", "1", list(failed)) + + +def test_queued_section_renders_numbers_reasons_days_only(): + gh = IntakeGitHub( + labeled=[labeled_pull(301)], + comments={301: [intake_comment("")]}, + runs={"workflow_runs": [{"id": 777, "conclusion": "success", "created_at": "2026-11-01T03:00:00Z"}]}, + ) + r, body = intake_body(gh) + assert r["queued_pulls"] == [[301, "no_accepted", 31, "62:2026-10-02"]] + assert "## Queued pull requests" in body + assert "- #301: no_accepted, 31 days" in body + assert "the nightly intake sweep run 777 concluded success" in body + assert "- #62: commented 30 days ago by the author of PR #301 (14 days or more)" in body + assert "Any comment by the author before the pull request counts, so read the issue before acting." in body + assert (f"[All queued pull requests with authors](https://github.com/{REPO}/pulls?q=" + "is%3Apr+is%3Aopen+label%3Astatus%3Aneeds-triage)") in body + assert "outsider" not in body and "SECRET TITLE" not in body and "Queued text" not in body + assert ("get", f"repos/{REPO}/actions/workflows/pr-intake.yml/runs?event=schedule&status=completed&per_page=1") in gh.paths + + +def test_queued_section_says_when_no_scheduled_run_completed(): + _r, body = intake_body(IntakeGitHub()) + assert "No completed scheduled run of the nightly intake sweep." in body + + +def test_queued_section_ignores_a_data_line_from_anyone_but_the_bot(): + forged = intake_comment("", login="outsider") + r, body = intake_body(IntakeGitHub(labeled=[labeled_pull(302)], comments={302: [forged]})) + assert r["queued_pulls"] == [] and r["queued_unrecorded"] == [302] + assert r["intake_counts"]["claims_waiting"] == 0 + assert "Labeled without a gate record" in body and "- #302" in body + + +@pytest.mark.parametrize("line", [ + "", + "", + "", + "", + "", + "not a data line", +]) +def test_queued_section_drops_a_malformed_data_line(line): + r, body = intake_body(IntakeGitHub(labeled=[labeled_pull(303)], comments={303: [intake_comment(line)]})) + assert r["queued_pulls"] == [] and r["queued_unrecorded"] == [] + assert r["queued_unreadable"] == 1 + assert "bogus" not in body and "yesterday" not in body and "soon" not in body + assert "Data lines that could not be read: 1." in body + + +def test_queued_headings_for_thirty_days_awaiting_sent_back_and_unrecorded(): + body = render_health(report(), "ok", "t", "1", []) + section = body.split("## Queued pull requests", 1)[1].split("\n## ", 1)[0] + for heading in ("Queued more than 30 days", "Awaiting re-review", "Sent back, no response", "Labeled without a gate record"): + assert heading in section + after = lambda heading: section.split(heading, 1)[1].split("\n\n", 2)[1] # noqa: E731 + assert after("Queued more than 30 days").splitlines() == ["- #101: no_accepted, 42 days"] + assert after("Awaiting re-review").splitlines() == ["- #102: awaiting_rereview, 7 days"] + assert after("Sent back, no response").splitlines() == ["- #103: sent_back, 22 days"] + assert after("Labeled without a gate record").splitlines() == ["- #104"] + # Grouped by reason, in the gate's reason order. + assert section.index("- #101: no_accepted") < section.index("- #103: sent_back") < section.index("- #102: awaiting_rereview") + + +def test_claims_counted_once_per_author_and_issue(): + line = "" + gh = IntakeGitHub( + labeled=[labeled_pull(311, "alice"), labeled_pull(312, "Alice"), labeled_pull(313, "bob")], + comments={ + 311: [intake_comment(line.format("62:2026-10-20,63:2026-10-30"))], + 312: [intake_comment(line.format("62:2026-10-10"))], + 313: [intake_comment(line.format("62:2026-10-25"))], + }, + ) + r, body = intake_body(gh) + assert r["intake_counts"]["claims_waiting"] == 3 + claims = body.split("Claims waiting on a lead", 1)[1].split("Any comment", 1)[0] + assert [row for row in claims.splitlines() if row.startswith("- ")] == [ + "- #62: commented 22 days ago by the author of PR #312 (14 days or more)", + "- #62: commented 7 days ago by the author of PR #313", + "- #63: commented 2 days ago by the author of PR #311", + ] + assert "alice" not in body.lower() and "bob" not in body + + +def test_hand_cleared_counts_the_route_not_the_merge(): + cleared = "" + gh = IntakeGitHub( + closed=[ + {"number": 401, "user": {"login": "rocklambros"}, "closed_at": "2026-10-20T00:00:00Z", "merged_at": "2026-10-20T00:00:00Z"}, + {"number": 402, "user": {"login": "outsider"}, "closed_at": "2026-10-21T00:00:00Z", "merged_at": "2026-10-21T00:00:00Z"}, + {"number": 403, "user": {"login": "outsider"}, "closed_at": "2026-08-15T00:00:00Z", "merged_at": "2026-08-15T00:00:00Z"}, + {"number": 404, "user": {"login": "outsider"}, "closed_at": "2026-10-22T00:00:00Z", "merged_at": None}, + {"number": 405, "user": {"login": "outsider"}, "closed_at": "2026-05-01T00:00:00Z", "merged_at": "2026-05-01T00:00:00Z"}, + ], + comments={ + 401: [intake_comment(cleared.format("hand"))], + 402: [intake_comment(cleared.format("review"))], + 403: [intake_comment(cleared.format("hand"))], + }, + ) + r, body = intake_body(gh) + assert r["intake_counts"]["hand_cleared_60d"] == 1 + assert r["intake_counts"]["non_roster_merges_60d"] == 1 + assert r["intake_counts"]["non_roster_merges_prior_60d"] == 1 + assert "Hand-cleared in the last 60 days: 1." in body + assert "Non-roster merges in the last 60 days: 1, against 1 in the 60 days before." in body + + +def test_intake_recent_keeps_no_login(): + from roadmap_sync import intake_snapshot + gh = IntakeGitHub(closed=[{"number": 401, "user": {"login": "outsider"}, "closed_at": "2026-10-20T00:00:00Z", "merged_at": None}]) + snapshot = intake_snapshot(gh, date(2026, 11, 1), TRUSTED) + assert snapshot["intake_recent"] == [[401, False, None, None]] + assert "outsider" not in repr(snapshot) + + +def test_baseline_line_when_unset_and_when_set(monkeypatch): + monkeypatch.delenv("PR_INTAKE_BASELINES", raising=False) + body = render_health(report(), "ok", "t", "1", []) + assert "Baseline not recorded. A project lead sets PR_INTAKE_BASELINES." in body + assert "Review due" not in body + monkeypatch.setenv("PR_INTAKE_BASELINES", "recorded=2026-11-01 median_age=12 hand_cleared_60d=3 non_roster_merges_60d=4") + body = render_health(report(), "ok", "t", "1", []) + assert "Baseline recorded 2026-11-01: median age 12 days, 3 hand-cleared, 4 non-roster merges in 60 days." in body + assert ("Review due 2026-12-31: if claims waiting rose or non-roster merges fell against the prior 60 days, " + "a project lead sets PR_INTAKE_ENFORCED=false.") in body + monkeypatch.setenv("PR_INTAKE_BASELINES", "recorded=soon x") + body = render_health(report(), "ok", "t", "1", []) + assert "Baseline not recorded." in body and "" not in body + + +class FailingIntakeGitHub(FactsGitHub): + def get(self, path): + raise SyncError("boom") + + +def test_intake_read_failure_degrades_one_section(): + import roadmap_sync + + def git(args): + return 0, "false\n" + + failed = [] + snapshot = roadmap_sync._snapshot(FailingIntakeGitHub(), git, date(2026, 11, 1), failed) + assert "queued_pulls" in failed + assert "in_focus_without_milestone" not in failed and "accepted_this_week" not in failed + assert snapshot["queued_pulls"] == [] and snapshot["intake_recent"] == [] and snapshot["intake_sweep"] == [] + r = build_report(snapshot, TRUSTED, LEADS, {"Spec"}, date(2026, 11, 1), SWITCHES) + body = render_health(r, "degraded", "t", "1", failed) + section = body.split("## Queued pull requests", 1)[1].split("\n## ", 1)[0] + assert "Could not be read on this run." in section diff --git a/tests/test_roadmap_sync_workflow.py b/tests/test_roadmap_sync_workflow.py index e9ec2a5d..c0e8f4c1 100644 --- a/tests/test_roadmap_sync_workflow.py +++ b/tests/test_roadmap_sync_workflow.py @@ -12,6 +12,7 @@ "ROADMAP_REFRESH_ENABLED": "${{ vars.ROADMAP_REFRESH_ENABLED }}", "ROADMAP_SYNC_ENABLED": "${{ vars.ROADMAP_SYNC_ENABLED }}", "ROADMAP_HEALTH_ISSUE": "${{ vars.ROADMAP_HEALTH_ISSUE }}", + "PR_INTAKE_BASELINES": "${{ vars.PR_INTAKE_BASELINES }}", } @@ -51,8 +52,8 @@ def test_jobs_are_exact(): ) assert jobs["dryrun"]["if"] == "github.event_name == 'workflow_dispatch' && inputs.mode != 'sweep'" assert jobs["event"]["permissions"] == {"contents": "read", "issues": "write"} - assert jobs["sweep"]["permissions"] == {"contents": "read", "issues": "write", "pull-requests": "read"} - assert jobs["dryrun"]["permissions"] == {"contents": "read", "issues": "read", "pull-requests": "read"} + assert jobs["sweep"]["permissions"] == {"actions": "read", "contents": "read", "issues": "write", "pull-requests": "read"} + assert jobs["dryrun"]["permissions"] == {"actions": "read", "contents": "read", "issues": "read", "pull-requests": "read"} # Keyed per issue. A newer pending run replacing an older one for the same issue loses # nothing, because each run re-reads the issue's live state. No `queue: max`: actionlint # 1.7.12 rejects it, zizmor does not validate it, and a stalled-dispatch report exists. diff --git a/tools/roadmap_sync.py b/tools/roadmap_sync.py index b7b73428..a75a35e3 100644 --- a/tools/roadmap_sync.py +++ b/tools/roadmap_sync.py @@ -31,6 +31,7 @@ import closing_choice # noqa: E402 import fetch_roadmap # noqa: E402 import roadmap_model as model # noqa: E402 +from pr_intake import CLEAR_ROUTES, MARKER, REASONS, parse_data_line # noqa: E402 GH_TIMEOUT_SECONDS = 30 @@ -285,6 +286,7 @@ def cmd_migrate(args) -> int: import os # noqa: E402 +import statistics # noqa: E402 from datetime import date, datetime, timedelta, timezone # noqa: E402 SECTION_TITLES = ( @@ -301,6 +303,7 @@ def cmd_migrate(args) -> int: ("unknown_close_reasons", "Unknown close reasons", "Closed with a reason this version does not know."), ("off_quarter_dates", "Off-quarter dates", "Milestones whose due date is not the last day of a quarter."), ("missing_description_lines", "Missing description lines", "Open milestones without a valid Type line, which the OWASP report needs."), + ("queued_pulls", "Queued pull requests", "Open pull requests the intake gate holds, by reason, with how long each has waited. Numbers only. The search link lists authors. This tool runs from main and reports nothing until promotion."), ("promotion", "Promotion", "Work on integration that main lacks. A project lead opens and merges the promotion."), ("bypasses", "Bypasses", "Merges into integration or main in the last seven days with no approving review, and direct pushes. Admins can bypass the rulesets, so this is where a bypass shows."), ("switches", "Switches", "The current value of each roadmap variable, flagged when it is neither true nor false."), @@ -421,6 +424,251 @@ def bypasses(gh, git, today: date) -> list[list]: } +# The intake gate's queue. Everything here is read from the bot's data line, which only the +# bot writes, and from GitHub-set numbers and dates. Logins are used to dedupe claims and to +# test roster membership, and are dropped before anything is stored. +INTAKE_RECENT_DAYS = 120 +INTAKE_WINDOW_DAYS = 60 +CLAIM_FLAG_DAYS = 14 +QUEUED_LONG_DAYS = 30 +INTAKE_RUNS = "actions/workflows/pr-intake.yml/runs?event=schedule&status=completed&per_page=1" +QUEUED_SEARCH = "pulls?q=is%3Apr+is%3Aopen+label%3Astatus%3Aneeds-triage" +_CLAIM = re.compile(r"^([0-9]{1,9}):([0-9]{4}-[0-9]{2}-[0-9]{2})$") +_CONCLUSION = re.compile(r"^[a-z_]{1,32}$") +_BASELINE = re.compile( + r"^recorded=([0-9]{4}-[0-9]{2}-[0-9]{2}) median_age=([0-9]{1,6}) " + r"hand_cleared_60d=([0-9]{1,6}) non_roster_merges_60d=([0-9]{1,6})$" +) + + +def _iso_day(value: object) -> date | None: + if not isinstance(value, str) or not _DAY.fullmatch(value): + return None + try: + return date.fromisoformat(value) + except ValueError: + return None + + +def _parse_claims(value: object) -> list[tuple[int, date]] | None: + """The `claimed` list as (issue, day) pairs, or None when any entry does not read.""" + if value in (None, ""): + return [] + if not isinstance(value, str): + return None + claims = [] + for entry in value.split(","): + match = _CLAIM.fullmatch(entry) + day = _iso_day(match.group(2)) if match else None + if day is None: + return None + claims.append((int(match.group(1)), day)) + return claims + + +def _readable_queue(reason: object, since: object, claimed: object) -> bool: + return reason in REASONS and _iso_day(since) is not None and _parse_claims(claimed) is not None + + +def _intake_data(comments: list) -> tuple[bool, dict[str, str] | None]: + """Whether the bot left its intake comment, and that comment's data line. + + Mirrors pr_intake's choice: the first comment by the bot's exact login that carries the + marker. A data line in anyone else's comment carries no authority. + """ + for comment in comments: + if not isinstance(comment, dict) or (comment.get("user") or {}).get("login") != model.BOT_LOGIN: + continue + body = comment.get("body") + if isinstance(body, str) and MARKER in body: + return True, parse_data_line(body) + return False, None + + +def _when(value: object) -> datetime | None: + if not isinstance(value, str): + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + +def intake_snapshot(gh, today: date, trusted: frozenset[str]) -> dict: + """The intake gate's queue, the last scheduled sweep, and recent closes. + + Claims are deduped by author and issue here, so no login reaches the snapshot. + """ + base = f"repos/{model.REPO}" + queued: list[list] = [] + claims: dict[tuple[str, int], list[int]] = {} + label = urllib.parse.quote(model.NEEDS_TRIAGE, safe="") + for item in gh.paginate(f"{base}/issues?state=open&labels={label}&per_page=100"): + if "pull_request" not in item or item.get("state") != "open" or model.NEEDS_TRIAGE not in _labels(item): + continue + number = int(item["number"]) + created = item.get("created_at") + found, data = _intake_data(gh.paginate(f"{base}/issues/{number}/comments?per_page=100")) + if not found: + queued.append([number, None, None, "", created]) + continue + # A marked comment whose line 1 does not parse is stored with an empty reason, which + # build_report counts as unreadable rather than as a pull request with no record. + data = data or {} + reason, since, claimed = data.get("reason", ""), data.get("since", ""), data.get("claimed", "") + queued.append([number, reason, since, claimed, created]) + if not _readable_queue(reason, since, claimed): + continue + author = str((item.get("user") or {}).get("login") or "").casefold() + for issue, day in _parse_claims(claimed): + row = [issue, (today - day).days, number] + best = claims.get((author, issue)) + # The earliest comment stands for the author, the lower pull request on a tie. + if best is None or (row[1], -row[2]) > (best[1], -best[2]): + claims[(author, issue)] = row + runs = gh.get(f"{base}/{INTAKE_RUNS}") + latest = (runs.get("workflow_runs") or [None])[0] if isinstance(runs, dict) else None + sweep: list = [] + if isinstance(latest, dict): + conclusion = latest.get("conclusion") + created = latest.get("created_at") + sweep = [ + conclusion if isinstance(conclusion, str) and _CONCLUSION.fullmatch(conclusion) else None, + int(latest["id"]), + created if _when(created) is not None else None, + ] + cutoff = datetime.combine(today - timedelta(days=INTAKE_RECENT_DAYS), datetime.min.time(), timezone.utc) + recent: list[list] = [] + for pull in gh.paginate(f"{base}/pulls?state=closed&sort=updated&direction=desc&per_page=100"): + closed = _when(pull.get("closed_at")) + if closed is None or closed < cutoff: + continue + number = int(pull["number"]) + _found, data = _intake_data(gh.paginate(f"{base}/issues/{number}/comments?per_page=100")) + route = (data or {}).get("route") + merged = pull.get("merged_at") + author = str((pull.get("user") or {}).get("login") or "").casefold() + recent.append([ + number, author in trusted, route if route in CLEAR_ROUTES else None, merged if _when(merged) is not None else None, + ]) + return { + "queued_pulls": queued, + "intake_claims": sorted(claims.values(), key=lambda row: (-row[1], row[0], row[2])), + "intake_sweep": sweep, + "intake_recent": recent, + } + + +def _intake_report(snapshot: dict, today: date) -> dict: + queued: list[list] = [] + unrecorded: list[int] = [] + unreadable = 0 + for number, reason, since, claimed, created in snapshot.get("queued_pulls") or []: + opened = _when(created) + if reason is None and opened is not None: + unrecorded.append(int(number)) + elif opened is None or not _readable_queue(reason, since, claimed): + unreadable += 1 + else: + queued.append([int(number), reason, (today - opened.date()).days, claimed]) + queued.sort(key=lambda row: (-row[2], row[0])) + + def merged_age(row: list) -> int | None: + merged = _when(row[3]) + return (today - merged.date()).days if merged is not None else None + + recent = [(row, merged_age(row)) for row in snapshot.get("intake_recent") or []] + window = INTAKE_WINDOW_DAYS + claims = [list(row) for row in snapshot.get("intake_claims") or []] + return { + "queued_pulls": queued, + "queued_unrecorded": unrecorded, + "queued_unreadable": unreadable, + "queued_meta": list(snapshot.get("intake_sweep") or []), + "queued_claims": claims, + "intake_counts": { + "median_age_days": statistics.median_low([row[2] for row in queued]) if queued else None, + "hand_cleared_60d": sum(1 for row, age in recent if row[2] == "hand" and age is not None and 0 <= age < window), + "non_roster_merges_60d": sum(1 for row, age in recent if not row[1] and age is not None and 0 <= age < window), + "non_roster_merges_prior_60d": sum( + 1 for row, age in recent if not row[1] and age is not None and window <= age < 2 * window + ), + "claims_waiting": len(claims), + }, + } + + +def _render_queued(report: dict) -> list[str]: + """Numbers, reason codes, dates, and days only. No login and no fetched text.""" + repo_url = f"https://github.com/{model.REPO}" + lines: list[str] = [] + meta = report.get("queued_meta") or [] + if meta: + conclusion, run_id, created = meta + day = f" on {created[:10]}" if created else "" + lines.append( + f"Last scheduled run: the nightly intake sweep run {int(run_id)} " + f"concluded {conclusion or 'with no readable conclusion'}{day}." + ) + else: + lines.append("No completed scheduled run of the nightly intake sweep.") + counts = report.get("intake_counts") or {} + median = counts.get("median_age_days") + lines += ["", " ".join([ + f"Median age {median} days." if median is not None else "Median age: nothing queued.", + f"Hand-cleared in the last 60 days: {counts.get('hand_cleared_60d', 0)}.", + f"Non-roster merges in the last 60 days: {counts.get('non_roster_merges_60d', 0)}, " + f"against {counts.get('non_roster_merges_prior_60d', 0)} in the 60 days before.", + f"Claims waiting on a lead: {counts.get('claims_waiting', 0)}.", + ])] + baseline = _BASELINE.fullmatch(os.environ.get("PR_INTAKE_BASELINES", "").strip()) + recorded = _iso_day(baseline.group(1)) if baseline else None + if recorded is None: + lines += ["", "Baseline not recorded. A project lead sets PR_INTAKE_BASELINES."] + else: + due = recorded + timedelta(days=INTAKE_WINDOW_DAYS) + lines += [ + "", + f"Baseline recorded {recorded.isoformat()}: median age {int(baseline.group(2))} days, " + f"{int(baseline.group(3))} hand-cleared, {int(baseline.group(4))} non-roster merges in 60 days.", + "", + f"Review due {due.isoformat()}: if claims waiting rose or non-roster merges fell against the prior " + "60 days, a project lead sets PR_INTAKE_ENFORCED=false.", + ] + unreadable = int(report.get("queued_unreadable") or 0) + if unreadable: + lines += ["", f"Data lines that could not be read: {unreadable}."] + claims = report.get("queued_claims") or [] + if claims: + lines += ["", "Claims waiting on a lead:", ""] + for issue, days, number in claims: + flag = " (14 days or more)" if days >= CLAIM_FLAG_DAYS else "" + lines.append(f"- #{int(issue)}: commented {int(days)} days ago by the author of PR #{int(number)}{flag}") + lines += ["", "Any comment by the author before the pull request counts, so read the issue before acting."] + queued = report.get("queued_pulls") or [] + + def rows(selected: list[list]) -> list[str]: + return [f"- #{int(number)}: {reason}, {int(days)} days" for number, reason, days, _claimed in selected] + + lines.append("") + if queued: + # Grouped by reason in the gate's own reason order, longest wait first in each group. + lines += rows(sorted(queued, key=lambda row: REASONS.index(row[1]))) + else: + lines.append("None queued with a gate record.") + unrecorded = report.get("queued_unrecorded") or [] + for heading, selected in ( + ("Queued more than 30 days", rows([row for row in queued if row[2] > QUEUED_LONG_DAYS])), + ("Awaiting re-review", rows([row for row in queued if row[1] == "awaiting_rereview"])), + ("Sent back, no response", rows([row for row in queued if row[1] == "sent_back"])), + ("Labeled without a gate record", [f"- #{int(number)}" for number in unrecorded]), + ): + if selected: + lines += ["", f"{heading}:", "", *selected] + lines += ["", f"[All queued pull requests with authors]({repo_url}/{QUEUED_SEARCH})"] + return lines + + def record_from_rest(issue: dict, facts: dict | None) -> model.IssueRecord: """A REST issue plus the close facts the fetch wrote for it. @@ -500,9 +748,13 @@ def build_report( # Every switch is listed, so the weekly call sees that rendering is off, not only that a # value is odd. The third element flags anything other than true or false. report["switches"] = [[name, value, value not in ("true", "false")] for name, value in sorted(switches.items())] + report.update(_intake_report(snapshot, today)) report["unverified_closes"].sort() for key in report: - if key not in ("accepted_this_week", "switches", "unverified_closes", "promotion", "bypasses"): + if key not in ( + "accepted_this_week", "switches", "unverified_closes", "promotion", "bypasses", "queued_pulls", + "intake_recent", "queued_meta", "queued_claims", "queued_unreadable", "intake_counts", + ): report[key] = sorted(set(report[key])) return report @@ -529,7 +781,9 @@ def render_health(report: dict, status: str, stamp: str, run_id: str, failed: li lines.append("Could not be read on this run.") continue items = report.get(key) or [] - if not items: + if key == "queued_pulls": + lines += _render_queued(report) + elif not items: lines.append("None.") elif key == "accepted_this_week": lines += [f"- #{number}, milestone set by `{login}`" for number, login in items] @@ -590,7 +844,7 @@ def _snapshot(gh: GitHub, git, today: date, failed: list[str]) -> dict: base = f"repos/{model.REPO}" snapshot = { "milestones": [], "milestoned": [], "unmilestoned_open": [], "facts": {}, "bot_accepted": [], "promotion": [], - "bypasses": [], + "bypasses": [], "queued_pulls": [], "intake_claims": [], "intake_sweep": [], "intake_recent": [], } snapshot["milestones"] = gh.paginate(f"{base}/milestones?state=all&per_page=100") snapshot["milestoned"] = [i for i in gh.paginate(f"{base}/issues?milestone=*&state=all&per_page=100") if "pull_request" not in i] @@ -628,6 +882,12 @@ def _snapshot(gh: GitHub, git, today: date, failed: list[str]) -> dict: snapshot["bypasses"] = bypasses(gh, git, today) except (SyncError, KeyError, ValueError, TypeError, AttributeError): failed.append("bypasses") + try: + # Read here, not in _sweep, so _snapshot keeps its signature. Only booleans are kept. + trusted = model.trusted_logins(Path(__file__).resolve().parents[1]) + snapshot.update(intake_snapshot(gh, today, trusted)) + except (SyncError, KeyError, ValueError, TypeError, AttributeError): + failed.append("queued_pulls") cutoff = datetime.combine(today - timedelta(days=8), datetime.min.time(), timezone.utc) try: for raw in snapshot["milestoned"]: From 594762005b25caea0e564e21748e816356a61f44 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 21:37:10 -0600 Subject: [PATCH 14/31] Bound the intake health reads, skip bot merges, and name legacy queue comments Signed-off-by: rocklambros --- tests/test_roadmap_sync_health.py | 99 ++++++++++++++++++++++++-- tools/roadmap_sync.py | 111 +++++++++++++++++++++--------- 2 files changed, 173 insertions(+), 37 deletions(-) diff --git a/tests/test_roadmap_sync_health.py b/tests/test_roadmap_sync_health.py index 0edfbaba..76e99c77 100644 --- a/tests/test_roadmap_sync_health.py +++ b/tests/test_roadmap_sync_health.py @@ -361,21 +361,25 @@ def labeled_pull(number, author="outsider", created="2026-10-01T00:00:00Z"): } +CLOSED_PAGE = f"repos/{REPO}/pulls?state=closed&sort=updated&direction=desc&per_page=100&page=" + + class IntakeGitHub: - def __init__(self, labeled=(), comments=None, closed=(), runs=None): + """Serves the intake reads. `closed` is page 1 of the closed listing, `pages` every page.""" + + def __init__(self, labeled=(), comments=None, closed=(), runs=None, pages=None): self.labeled = list(labeled) self.comments = comments or {} - self.closed = list(closed) + self.pages = pages if pages is not None else [list(closed)] self.runs = runs if runs is not None else {"workflow_runs": []} self.paths = [] def paginate(self, path): self.paths.append(("paginate", path)) assert "actions/" not in path, "the runs path returns an object, never a list of pages" + assert "/pulls?" not in path, "the closed listing is read page by page so it can stop early" if "/comments" in path: return self.comments.get(int(path.split("/issues/")[1].split("/")[0]), []) - if "/pulls?" in path: - return self.closed if "/issues?" in path: return self.labeled raise AssertionError(path) @@ -384,8 +388,21 @@ def get(self, path): self.paths.append(("get", path)) if path.endswith("actions/workflows/pr-intake.yml/runs?event=schedule&status=completed&per_page=1"): return self.runs + if path.startswith(CLOSED_PAGE): + page = int(path[len(CLOSED_PAGE):]) + return self.pages[page - 1] if page <= len(self.pages) else [] raise AssertionError(path) + def requested(self, kind): + return [path for how, path in self.paths if kind in path] + + +def closed_pull(number, closed, merged=None, login="outsider", updated=None): + return { + "number": number, "user": {"login": login}, "closed_at": closed, "merged_at": merged, + "updated_at": updated or closed, + } + def intake_body(gh, today=date(2026, 11, 1), trusted=TRUSTED, failed=()): from roadmap_sync import intake_snapshot @@ -486,6 +503,9 @@ def test_hand_cleared_counts_the_route_not_the_merge(): {"number": 403, "user": {"login": "outsider"}, "closed_at": "2026-08-15T00:00:00Z", "merged_at": "2026-08-15T00:00:00Z"}, {"number": 404, "user": {"login": "outsider"}, "closed_at": "2026-10-22T00:00:00Z", "merged_at": None}, {"number": 405, "user": {"login": "outsider"}, "closed_at": "2026-05-01T00:00:00Z", "merged_at": "2026-05-01T00:00:00Z"}, + # Bot merges are not outside contributions, in either window. + closed_pull(406, "2026-10-23T00:00:00Z", "2026-10-23T00:00:00Z", login="dependabot[bot]"), + closed_pull(407, "2026-08-10T00:00:00Z", "2026-08-10T00:00:00Z", login="Dependabot[bot]"), ], comments={ 401: [intake_comment(cleared.format("hand"))], @@ -494,6 +514,8 @@ def test_hand_cleared_counts_the_route_not_the_merge(): }, ) r, body = intake_body(gh) + from roadmap_sync import intake_snapshot + assert [row[0] for row in intake_snapshot(gh, date(2026, 11, 1), TRUSTED)["intake_recent"]] == [401, 402, 403, 404] assert r["intake_counts"]["hand_cleared_60d"] == 1 assert r["intake_counts"]["non_roster_merges_60d"] == 1 assert r["intake_counts"]["non_roster_merges_prior_60d"] == 1 @@ -544,3 +566,72 @@ def git(args): body = render_health(r, "degraded", "t", "1", failed) section = body.split("## Queued pull requests", 1)[1].split("\n## ", 1)[0] assert "Could not be read on this run." in section + + +def test_closed_listing_stops_at_the_window_start(): + # The window starts 2026-07-04. Page 1 ends on a pull request updated before it, and + # closed_at is never later than updated_at, so page 2 cannot hold anything in the window. + first = [closed_pull(500 + n, "2026-10-01T00:00:00Z") for n in range(99)] + first.append(closed_pull(599, "2026-06-01T00:00:00Z")) + second = [closed_pull(600, "2026-10-02T00:00:00Z")] + gh = IntakeGitHub(pages=[first, second]) + from roadmap_sync import intake_snapshot + snapshot = intake_snapshot(gh, date(2026, 11, 1), TRUSTED) + assert gh.requested("&page=") == [CLOSED_PAGE + "1"] + assert len(snapshot["intake_recent"]) == 99 + + +def test_closed_listing_reads_the_next_page_while_inside_the_window(): + first = [closed_pull(500 + n, "2026-10-01T00:00:00Z") for n in range(100)] + second = [closed_pull(700, "2026-09-01T00:00:00Z")] + gh = IntakeGitHub(pages=[first, second]) + from roadmap_sync import intake_snapshot + snapshot = intake_snapshot(gh, date(2026, 11, 1), TRUSTED) + assert gh.requested("&page=") == [CLOSED_PAGE + "1", CLOSED_PAGE + "2"] + assert len(snapshot["intake_recent"]) == 101 + + +def test_closed_listing_past_the_page_cap_degrades_rather_than_undercounts(): + from roadmap_sync import CLOSED_PAGE_CAP, intake_snapshot + full = [closed_pull(500 + n, "2026-10-01T00:00:00Z") for n in range(100)] + gh = IntakeGitHub(pages=[full] * (CLOSED_PAGE_CAP + 1)) + with pytest.raises(SyncError, match="page"): + intake_snapshot(gh, date(2026, 11, 1), TRUSTED) + assert len(gh.requested("&page=")) == CLOSED_PAGE_CAP + + +def test_comments_are_read_only_for_merges_in_the_last_sixty_days(): + hand = "" + gh = IntakeGitHub( + closed=[ + closed_pull(801, "2026-10-20T00:00:00Z", "2026-10-20T00:00:00Z"), + closed_pull(802, "2026-08-03T00:00:00Z", "2026-08-03T00:00:00Z"), + closed_pull(803, "2026-10-21T00:00:00Z", None), + ], + comments={801: [intake_comment(hand)], 802: [intake_comment(hand)], 803: [intake_comment(hand)]}, + ) + from roadmap_sync import intake_snapshot + snapshot = intake_snapshot(gh, date(2026, 11, 1), TRUSTED) + assert gh.requested("/comments") == [f"repos/{REPO}/issues/801/comments?per_page=100"] + assert snapshot["intake_recent"] == [ + [801, False, "hand", "2026-10-20T00:00:00Z"], [802, False, None, "2026-08-03T00:00:00Z"], [803, False, None, None], + ] + + +def test_a_far_future_baseline_reads_as_not_recorded(monkeypatch): + monkeypatch.setenv("PR_INTAKE_BASELINES", "recorded=9999-12-01 median_age=1 hand_cleared_60d=1 non_roster_merges_60d=1") + body = render_health(report(), "ok", "t", "1", []) + assert "Baseline not recorded. A project lead sets PR_INTAKE_BASELINES." in body + assert "9999" not in body + + +def test_a_legacy_intake_comment_is_not_called_a_missing_record(): + legacy = {"user": {"login": "github-actions[bot]"}, "body": "Thanks for this. It is queued rather than ignored. More text."} + forged = {"user": {"login": "outsider"}, "body": "Thanks for this. It is queued rather than ignored."} + gh = IntakeGitHub(labeled=[labeled_pull(321), labeled_pull(322)], comments={321: [legacy], 322: [forged]}) + r, body = intake_body(gh) + assert r["queued_legacy"] == [321] and r["queued_unrecorded"] == [322] + section = body.split("## Queued pull requests", 1)[1].split("\n## ", 1)[0] + assert section.split("Queued under the earlier intake comment:", 1)[1].split("\n\n", 2)[1].splitlines() == ["- #321"] + assert section.split("Labeled without a gate record:", 1)[1].split("\n\n", 2)[1].splitlines() == ["- #322"] + assert "More text" not in body diff --git a/tools/roadmap_sync.py b/tools/roadmap_sync.py index a75a35e3..bcd10ea2 100644 --- a/tools/roadmap_sync.py +++ b/tools/roadmap_sync.py @@ -31,7 +31,7 @@ import closing_choice # noqa: E402 import fetch_roadmap # noqa: E402 import roadmap_model as model # noqa: E402 -from pr_intake import CLEAR_ROUTES, MARKER, REASONS, parse_data_line # noqa: E402 +from pr_intake import CLEAR_ROUTES, KNOWN_BOTS, LEGACY_KEYWORD_MARKER, LEGACY_PREFIX, MARKER, REASONS, parse_data_line # noqa: E402 GH_TIMEOUT_SECONDS = 30 @@ -432,6 +432,10 @@ def bypasses(gh, git, today: date) -> list[list]: CLAIM_FLAG_DAYS = 14 QUEUED_LONG_DAYS = 30 INTAKE_RUNS = "actions/workflows/pr-intake.yml/runs?event=schedule&status=completed&per_page=1" +CLOSED_PULLS = "pulls?state=closed&sort=updated&direction=desc&per_page=100&page=" +# Ten pages is 1,000 pull requests updated in 120 days, several times this repository's pace, +# and keeps a runaway listing inside the sweep's fifteen-minute job limit. +CLOSED_PAGE_CAP = 10 QUEUED_SEARCH = "pulls?q=is%3Apr+is%3Aopen+label%3Astatus%3Aneeds-triage" _CLAIM = re.compile(r"^([0-9]{1,9}):([0-9]{4}-[0-9]{2}-[0-9]{2})$") _CONCLUSION = re.compile(r"^[a-z_]{1,32}$") @@ -470,19 +474,23 @@ def _readable_queue(reason: object, since: object, claimed: object) -> bool: return reason in REASONS and _iso_day(since) is not None and _parse_claims(claimed) is not None -def _intake_data(comments: list) -> tuple[bool, dict[str, str] | None]: - """Whether the bot left its intake comment, and that comment's data line. +def _intake_data(comments: list) -> tuple[str | None, dict[str, str] | None]: + """Which intake comment the bot left ("marked", "legacy", or None), and its data line. - Mirrors pr_intake's choice: the first comment by the bot's exact login that carries the - marker. A data line in anyone else's comment carries no authority. + A data line in anyone else's comment carries no authority. """ - for comment in comments: - if not isinstance(comment, dict) or (comment.get("user") or {}).get("login") != model.BOT_LOGIN: - continue - body = comment.get("body") - if isinstance(body, str) and MARKER in body: - return True, parse_data_line(body) - return False, None + mine = [ + comment["body"] for comment in comments + if isinstance(comment, dict) and (comment.get("user") or {}).get("login") == model.BOT_LOGIN + and isinstance(comment.get("body"), str) + ] + # Mirrors pr_intake._bot_comment: the first marked comment by the bot login wins, then a legacy one. + for body in mine: + if MARKER in body: + return "marked", parse_data_line(body) + if any(body.startswith(LEGACY_PREFIX) or LEGACY_KEYWORD_MARKER in body for body in mine): + return "legacy", None + return None, None def _when(value: object) -> datetime | None: @@ -501,6 +509,7 @@ def intake_snapshot(gh, today: date, trusted: frozenset[str]) -> dict: """ base = f"repos/{model.REPO}" queued: list[list] = [] + legacy: list[int] = [] claims: dict[tuple[str, int], list[int]] = {} label = urllib.parse.quote(model.NEEDS_TRIAGE, safe="") for item in gh.paginate(f"{base}/issues?state=open&labels={label}&per_page=100"): @@ -508,8 +517,12 @@ def intake_snapshot(gh, today: date, trusted: frozenset[str]) -> dict: continue number = int(item["number"]) created = item.get("created_at") - found, data = _intake_data(gh.paginate(f"{base}/issues/{number}/comments?per_page=100")) - if not found: + kind, data = _intake_data(gh.paginate(f"{base}/issues/{number}/comments?per_page=100")) + if kind == "legacy": + # Queued by the comment the gate replaced, so it has a record, only an older one. + legacy.append(number) + continue + if kind is None: queued.append([number, None, None, "", created]) continue # A marked comment whose line 1 does not parse is stored with an empty reason, which @@ -537,28 +550,54 @@ def intake_snapshot(gh, today: date, trusted: frozenset[str]) -> dict: int(latest["id"]), created if _when(created) is not None else None, ] - cutoff = datetime.combine(today - timedelta(days=INTAKE_RECENT_DAYS), datetime.min.time(), timezone.utc) - recent: list[list] = [] - for pull in gh.paginate(f"{base}/pulls?state=closed&sort=updated&direction=desc&per_page=100"): - closed = _when(pull.get("closed_at")) - if closed is None or closed < cutoff: - continue - number = int(pull["number"]) - _found, data = _intake_data(gh.paginate(f"{base}/issues/{number}/comments?per_page=100")) - route = (data or {}).get("route") - merged = pull.get("merged_at") - author = str((pull.get("user") or {}).get("login") or "").casefold() - recent.append([ - number, author in trusted, route if route in CLEAR_ROUTES else None, merged if _when(merged) is not None else None, - ]) return { "queued_pulls": queued, + "queued_legacy": legacy, "intake_claims": sorted(claims.values(), key=lambda row: (-row[1], row[0], row[2])), "intake_sweep": sweep, - "intake_recent": recent, + "intake_recent": _intake_recent(gh, today, trusted), } +def _intake_recent(gh, today: date, trusted: frozenset[str]) -> list[list]: + """Pull requests closed in the last 120 days, read page by page, newest update first. + + GitHub.paginate fetches every page before it returns, so the listing is read through + get, which lets the loop stop at the first page that reaches past the window. + """ + base = f"repos/{model.REPO}" + cutoff = datetime.combine(today - timedelta(days=INTAKE_RECENT_DAYS), datetime.min.time(), timezone.utc) + recent: list[list] = [] + for page in range(1, CLOSED_PAGE_CAP + 1): + pulls = gh.get(f"{base}/{CLOSED_PULLS}{page}") + if not isinstance(pulls, list) or not pulls: + return recent + for pull in pulls: + closed = _when(pull.get("closed_at")) + author = str((pull.get("user") or {}).get("login") or "").casefold() + if closed is None or closed < cutoff or author in KNOWN_BOTS: + continue + number = int(pull["number"]) + merged = pull.get("merged_at") if _when(pull.get("merged_at")) is not None else None + route = None + # The route feeds only hand_cleared_60d, so comments are read only for merges in + # that window. They are the one read per pull request that dominates the cost. + if merged is not None and 0 <= (today - _when(merged).date()).days < INTAKE_WINDOW_DAYS: + _kind, data = _intake_data(gh.paginate(f"{base}/issues/{number}/comments?per_page=100")) + route = (data or {}).get("route") + # Counts follow today's roster, so promoting a contributor rewrites past merge + # counts in both windows. + trusted_author = author in trusted + recent.append([number, trusted_author, route if route in CLEAR_ROUTES else None, merged]) + # closed_at is never later than updated_at, so once a page reaches an update older + # than the window, no later page can hold a close inside it. + updated = [_when(pull.get("updated_at")) for pull in pulls if isinstance(pull, dict)] + if len(pulls) < 100 or any(when is None or when < cutoff for when in updated): + return recent + # Reporting a partial count as the full one would mislead the review, so the section degrades. + raise SyncError(f"closed pull request listing ran past {CLOSED_PAGE_CAP} pages") + + def _intake_report(snapshot: dict, today: date) -> dict: queued: list[list] = [] unrecorded: list[int] = [] @@ -583,6 +622,7 @@ def merged_age(row: list) -> int | None: return { "queued_pulls": queued, "queued_unrecorded": unrecorded, + "queued_legacy": [int(number) for number in snapshot.get("queued_legacy") or []], "queued_unreadable": unreadable, "queued_meta": list(snapshot.get("intake_sweep") or []), "queued_claims": claims, @@ -623,10 +663,14 @@ def _render_queued(report: dict) -> list[str]: ])] baseline = _BASELINE.fullmatch(os.environ.get("PR_INTAKE_BASELINES", "").strip()) recorded = _iso_day(baseline.group(1)) if baseline else None - if recorded is None: + try: + due = recorded + timedelta(days=INTAKE_WINDOW_DAYS) if recorded is not None else None + except OverflowError: + # A far-future date must not stop the health issue from being written. + due = None + if due is None: lines += ["", "Baseline not recorded. A project lead sets PR_INTAKE_BASELINES."] else: - due = recorded + timedelta(days=INTAKE_WINDOW_DAYS) lines += [ "", f"Baseline recorded {recorded.isoformat()}: median age {int(baseline.group(2))} days, " @@ -661,6 +705,7 @@ def rows(selected: list[list]) -> list[str]: ("Queued more than 30 days", rows([row for row in queued if row[2] > QUEUED_LONG_DAYS])), ("Awaiting re-review", rows([row for row in queued if row[1] == "awaiting_rereview"])), ("Sent back, no response", rows([row for row in queued if row[1] == "sent_back"])), + ("Queued under the earlier intake comment", [f"- #{int(number)}" for number in report.get("queued_legacy") or []]), ("Labeled without a gate record", [f"- #{int(number)}" for number in unrecorded]), ): if selected: @@ -753,7 +798,7 @@ def build_report( for key in report: if key not in ( "accepted_this_week", "switches", "unverified_closes", "promotion", "bypasses", "queued_pulls", - "intake_recent", "queued_meta", "queued_claims", "queued_unreadable", "intake_counts", + "queued_meta", "queued_claims", "queued_unreadable", "intake_counts", ): report[key] = sorted(set(report[key])) return report @@ -844,7 +889,7 @@ def _snapshot(gh: GitHub, git, today: date, failed: list[str]) -> dict: base = f"repos/{model.REPO}" snapshot = { "milestones": [], "milestoned": [], "unmilestoned_open": [], "facts": {}, "bot_accepted": [], "promotion": [], - "bypasses": [], "queued_pulls": [], "intake_claims": [], "intake_sweep": [], "intake_recent": [], + "bypasses": [], "queued_pulls": [], "queued_legacy": [], "intake_claims": [], "intake_sweep": [], "intake_recent": [], } snapshot["milestones"] = gh.paginate(f"{base}/milestones?state=all&per_page=100") snapshot["milestoned"] = [i for i in gh.paginate(f"{base}/issues?milestone=*&state=all&per_page=100") if "pull_request" not in i] From 02f2a41fcf2647941ae6af92973669ae7c8c58cb Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 21:41:40 -0600 Subject: [PATCH 15/31] Have the roadmap monitor raise the lead alarm when the intake sweep fails Signed-off-by: rocklambros --- .github/workflows/monitor-roadmap.yml | 5 +- tests/test_monitor_roadmap.py | 56 +++++++++++++++++++++-- tools/monitor_roadmap.py | 66 +++++++++++++++++++++++++-- 3 files changed, 118 insertions(+), 9 deletions(-) diff --git a/.github/workflows/monitor-roadmap.yml b/.github/workflows/monitor-roadmap.yml index ae8d5871..0b6394d4 100644 --- a/.github/workflows/monitor-roadmap.yml +++ b/.github/workflows/monitor-roadmap.yml @@ -21,10 +21,11 @@ permissions: {} jobs: check: # Scheduled runs come from the default branch but check out main, which may lack the monitor yet. - if: vars.ROADMAP_RENDER_ENABLED == 'true' || vars.ROADMAP_SYNC_ENABLED == 'true' + if: vars.ROADMAP_RENDER_ENABLED == 'true' || vars.ROADMAP_SYNC_ENABLED == 'true' || vars.PR_INTAKE_ENFORCED == 'true' runs-on: ubuntu-latest timeout-minutes: 5 permissions: + actions: read # reads the intake workflow's run list contents: read issues: write steps: @@ -39,5 +40,7 @@ jobs: ROADMAP_RENDER_ENABLED: ${{ vars.ROADMAP_RENDER_ENABLED }} ROADMAP_SYNC_ENABLED: ${{ vars.ROADMAP_SYNC_ENABLED }} ROADMAP_HEALTH_ISSUE: ${{ vars.ROADMAP_HEALTH_ISSUE }} + PR_INTAKE_ENFORCED: ${{ vars.PR_INTAKE_ENFORCED }} + PR_INTAKE_TOOL_SHA: ${{ vars.PR_INTAKE_TOOL_SHA }} TEST_ALARM: ${{ inputs.test_alarm }} run: python3 -I -S tools/monitor_roadmap.py diff --git a/tests/test_monitor_roadmap.py b/tests/test_monitor_roadmap.py index e6110cb0..ddba794d 100644 --- a/tests/test_monitor_roadmap.py +++ b/tests/test_monitor_roadmap.py @@ -4,7 +4,7 @@ import json import subprocess import sys -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path import pytest @@ -63,9 +63,10 @@ def test_check_health(configured, issue, problems): }}}}, "permissions": {}, "jobs": {"check": { - "if": "vars.ROADMAP_RENDER_ENABLED == 'true' || vars.ROADMAP_SYNC_ENABLED == 'true'", + "if": ("vars.ROADMAP_RENDER_ENABLED == 'true' || vars.ROADMAP_SYNC_ENABLED == 'true'" + " || vars.PR_INTAKE_ENFORCED == 'true'"), "runs-on": "ubuntu-latest", "timeout-minutes": 5, - "permissions": {"contents": "read", "issues": "write"}, + "permissions": {"actions": "read", "contents": "read", "issues": "write"}, "steps": [ {"uses": "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", "with": {"persist-credentials": False, "ref": "main"}}, @@ -76,6 +77,8 @@ def test_check_health(configured, issue, problems): "ROADMAP_RENDER_ENABLED": "${{ vars.ROADMAP_RENDER_ENABLED }}", "ROADMAP_SYNC_ENABLED": "${{ vars.ROADMAP_SYNC_ENABLED }}", "ROADMAP_HEALTH_ISSUE": "${{ vars.ROADMAP_HEALTH_ISSUE }}", + "PR_INTAKE_ENFORCED": "${{ vars.PR_INTAKE_ENFORCED }}", + "PR_INTAKE_TOOL_SHA": "${{ vars.PR_INTAKE_TOOL_SHA }}", "TEST_ALARM": "${{ inputs.test_alarm }}", }, "run": "python3 -I -S tools/monitor_roadmap.py"}, @@ -102,6 +105,53 @@ def test_evaluate_runs_only_switched_on_checks(render, sync, expected): assert len(problems) == expected +def test_monitor_workflow_passes_the_intake_switch(): + text = WORKFLOW.read_text(encoding="utf-8") + assert "PR_INTAKE_ENFORCED: ${{ vars.PR_INTAKE_ENFORCED }}" in text + assert "|| vars.PR_INTAKE_ENFORCED == 'true'" in text + assert "actions: read" in text + + +def sweep(conclusion="success", hours=5, status="completed", run_id=77): + updated = NOW - timedelta(hours=hours) + return {"id": run_id, "status": status, "conclusion": conclusion, "event": "schedule", + "updated_at": updated.strftime("%Y-%m-%dT%H:%M:%SZ")} + + +@pytest.mark.parametrize("runs, expected", [ + (None, ["no completed nightly intake sweep run exists"]), + ([], ["no completed nightly intake sweep run exists"]), + ([sweep(hours=31)], ["the nightly intake sweep run 77 is older than 30 hours"]), + ([sweep("failure")], ["the nightly intake sweep run 77 concluded failure"]), + ([sweep("cancelled")], ["the nightly intake sweep run 77 concluded cancelled"]), + ([sweep("skipped")], ["the nightly intake sweep run 77 concluded skipped"]), + ([sweep()], []), + ([sweep(status="in_progress", conclusion=None, run_id=78), sweep()], []), + ([sweep(status="in_progress", conclusion=None, run_id=78)], ["no completed nightly intake sweep run exists"]), +]) +def test_check_intake(runs, expected): + from monitor_roadmap import check_intake + assert check_intake(runs, NOW) == expected + + +def test_evaluate_runs_intake_only_when_enforced(): + from monitor_roadmap import evaluate + off = evaluate({"PR_INTAKE_ENFORCED": "false"}, read_json=lambda url: None, + read_issue=lambda n: None, now=NOW, read_runs=lambda: None) + on = evaluate({"PR_INTAKE_ENFORCED": "true"}, read_json=lambda url: None, + read_issue=lambda n: None, now=NOW, read_runs=lambda: None) + assert off == [] + assert on == ["no completed nightly intake sweep run exists"] + + +def test_evaluate_reads_enforced_from_env(): + from monitor_roadmap import evaluate + calls = [] + evaluate({"PR_INTAKE_ENFORCED": "true"}, read_json=lambda url: None, read_issue=lambda n: None, + now=NOW, read_runs=lambda: calls.append(1) or [sweep()]) + assert calls == [1] + + def test_check_page_never_echoes_fetched_status(): hostile = "bad\n::error::injected" problems = check_page({"status": hostile, "generated": "2026-11-02T05:20:00Z"}, NOW) diff --git a/tools/monitor_roadmap.py b/tools/monitor_roadmap.py index e4108f91..44e4ccd4 100644 --- a/tools/monitor_roadmap.py +++ b/tools/monitor_roadmap.py @@ -29,9 +29,12 @@ from datetime import datetime, timedelta, timezone # noqa: E402 import roadmap_model as model # noqa: E402 +from gh_runner import RunnerError, make_runner, split_http # noqa: E402 PAGE_MAX_AGE = timedelta(hours=36) HEALTH_MAX_AGE = timedelta(hours=50) +# A 03:10 UTC cron checked every six hours needs a day plus scheduler slack. +INTAKE_MAX_AGE = timedelta(hours=30) CLOCK_SKEW = timedelta(minutes=10) STATUS = re.compile(r" + `status:accepted` that a lead assigned to you, or a `help wanted` issue you commented + on first. Comment on the issue to claim it before opening the pull request. If you + open the pull request without that, it waits rather than being closed, and a bot + comment says which step is missing. See How work gets accepted in CONTRIBUTING.md. --> -- [ ] This is an editorial correction (typo, grammar, link, formatting) with no change in - meaning, so it needs no issue +- [ ] This is an editorial correction on the documentation lane (typo, grammar, link, formatting) with no change in meaning, so it needs no issue ## Base branch diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd74df1b..1f6e36d9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,21 +58,24 @@ quarter is not a finding without value. Anyone may open an issue. Only an issue carrying `status:accepted` enters the backlog. -A pull request that changes behavior, alters normative text, or adds code references an -accepted issue. An editorial correction does not, wherever it lands: a typo, a grammar -fix, a broken link, or a formatting repair that leaves the meaning untouched needs no -issue. +If you are a project lead, a workstream lead, or a code owner, your pull requests are not +queued. A lead's approval, or a lead's request for changes within the last 30 days, clears +the queue. If a lead has approved and merged a pull request of yours before, your pull +requests are not queued. An editorial fix to a page under `docs/topics/` or `design/` (a +typo, a link, formatting, no change in meaning) needs no issue: tick the editorial box in +the pull request template, and keep every changed file on that lane. Otherwise the pull +request needs an accepted issue that a lead assigned to you. Comment on the issue to claim +it before opening the pull request. The comment is the claim, and a lead assigns you. An +issue labeled `help wanted` is open to anyone who commented on it before opening the pull +request, two open pull requests per person per issue. A pull request that meets none of +these waits with the `status:needs-triage` label and a bot comment naming the missing +step. It is not closed. If a form, a template, or a bot comment disagrees with this +section, this section wins. In the pull request's issue section, write `Closes`, `Fixes`, or `Resolves` before an issue the change completes, and `Part of`, `Refs`, or `Contributes to` before one it only advances, because the roadmap counts a closed issue as delivered. -If you open a pull request against an issue that is not accepted yet, it will not be -reviewed and nothing about it is rejected. It waits, and a comment will say so. A pull -request that sits untriaged for a long time may be closed with an invitation to reopen -once its issue is accepted. Start from an issue labeled `help wanted` if you want work -that is already accepted. - [Triage authority](./GOVERNANCE.md#triage-authority) in GOVERNANCE.md says who applies `scope:`, `priority:`, `workstream:`, and `status:accepted`. No issue form can apply them, which is what makes the rule hold rather than depend on everyone remembering it. @@ -161,8 +164,8 @@ what an agent did to its own commits. ## What We Need Start with [issues labeled `help wanted`](https://github.com/GenAI-Security-Project/agent-control-standard/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22). -Every one of them is already accepted, which means you can open a pull request against it -without waiting on triage. +Every one of them is already accepted. Comment on one to claim it, then open the pull +request. The comment is the claim. The highest-value contribution right now is running ACS against a real harness and documenting where it fails. Install the reference implementation, wire it to a coding diff --git a/GOVERNANCE.md b/GOVERNANCE.md index c2c2b389..8a80e040 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -40,6 +40,10 @@ Anyone holding the repository's `triage` role or higher accepts an issue, either applying `status:accepted` or by setting a milestone. The roadmap sync applies `status:accepted` to a milestoned issue, so the two mean the same thing. +A lead or a triage volunteer holding the repository's triage role or higher accepts a +claim on an issue by assigning the claimant. GitHub lets a lead assign a non-collaborator +only after that person has commented on the issue, which is why the claim is a comment. + A workstream lead or a project lead may assign a volunteer or contributor to triage alongside them. An assigned volunteer applies any label, decision labels included, and the lead who assigned them reviews those calls and owns them. A project lead grants them the @@ -73,6 +77,9 @@ health issue reports every bypassed merge and every direct push. A project lead adds a row when a volunteer accepts the `triage` role, and removes it when they step back. +A roster entry also exempts that login's pull requests from intake and lets their +approval clear another contributor's, so add one only for a person who reviews here. + | Volunteer | Assigned by | | --- | --- | | Victor Hernandez ([@victorm-hernandez](https://github.com/victorm-hernandez)) | Kyriakos Lambros (Rock) | @@ -93,6 +100,8 @@ Existing leads propose additions and removals. The project leads confirm the cha The CODEOWNERS update is not optional. A lead who loses write access stops being a valid owner, and GitHub fails the entry silently rather than flagging it. +A roster entry also exempts that login's pull requests from intake and lets their approval clear another contributor's, so add one only for a person who reviews here. + ## Related - [CONTRIBUTING.md](./CONTRIBUTING.md) covers how to get involved. diff --git a/README.md b/README.md index 71be2b4b..8893112b 100644 --- a/README.md +++ b/README.md @@ -346,16 +346,19 @@ flowchart LR Every issue starts from one of six forms. There are no blank issues. A form applies a `type:` label and `status:needs-triage` automatically, and nothing else. [Triage authority](./GOVERNANCE.md#triage-authority) in GOVERNANCE.md says who can move an -issue to `status:accepted`, and only an accepted issue enters the backlog. A pull request that changes behavior, alters normative text, or adds code -references an accepted issue. An editorial correction, a typo or a broken link, needs -none. +issue to `status:accepted`, and only an accepted issue enters the backlog. A pull request +that changes behavior, alters normative text, or adds code needs an accepted issue that a +lead assigned to you, or a `help wanted` issue. Comment on the issue to claim it before +opening the pull request. The comment is the claim. An editorial correction to a page +under `docs/topics/` or `design/`, a typo or a broken link, needs none. ```mermaid flowchart LR F["Issue form"] --> T["Maintainer triage"] T -->|"deferred"| D["scope:deferred
tracked for the next spec release"] T -->|"accepted"| A["status:accepted
enters the backlog"] - A --> P["Pull request
references the issue"] + A --> C["Claim
comment on the issue"] + C --> P["Pull request
references the issue"] E["Editorial fix
typo, broken link"] --> P2["Pull request
needs no issue"] ``` diff --git a/tests/test_doc_links.py b/tests/test_doc_links.py index 5cf32afc..1e26d594 100644 --- a/tests/test_doc_links.py +++ b/tests/test_doc_links.py @@ -24,9 +24,7 @@ The scan originally covered `docs/` only, which is exactly why the 44 dead links above lived undetected: root files ship straight to readers too, README.md most of all, and carried zero coverage. `ROOT_MARKDOWN_FILES` below adds the front-door files this repo -actually renders for humans. `CONTRIBUTING.md` is deliberately absent from that list: a -pull request open at the time this guard was extended rewrites it, and adding it here -would race that rewrite rather than protect it. +actually renders for humans, `CONTRIBUTING.md` included. """ import json import re @@ -37,10 +35,10 @@ SPECIFICATION = ROOT / "specification" # Repository-root markdown files that ship to readers the same way docs/ does, so they -# get the same two guards. CONTRIBUTING.md is excluded on purpose; see the module -# docstring. +# get the same two guards. ROOT_MARKDOWN_FILES = ( "README.md", + "CONTRIBUTING.md", "GOVERNANCE.md", "SECURITY.md", "LICENSING.md", diff --git a/tests/test_governance_files.py b/tests/test_governance_files.py index 7f2d4d93..a0925a79 100644 --- a/tests/test_governance_files.py +++ b/tests/test_governance_files.py @@ -42,6 +42,12 @@ def test_codeowners_header_names_the_leads_and_the_lapsed_invitations(): assert "The project leads are @rocklambros,\n# @afogel, and @bar-capsule." in text assert "@artmaro holds write access." in text assert "lapsed and are not being re-sent" in text + # The header is the comment block before the first blank line, read unwrapped. + header = " ".join(line.lstrip("#").strip() for line in text.split("\n\n", 1)[0].splitlines()) + assert ( + "An owner login here is also exempt from pull request intake and can clear another" + " contributor's by approval." in header + ) def test_project_owasp_yaml_names_the_leads_and_the_creators(): diff --git a/tests/test_intake_prose_guard.py b/tests/test_intake_prose_guard.py new file mode 100644 index 00000000..21af9381 --- /dev/null +++ b/tests/test_intake_prose_guard.py @@ -0,0 +1,79 @@ +"""The contributor-facing text must say what the intake gate in tools/pr_intake.py does. + +Each test reads the shipped file, never a copy of the string, so a later edit to the +prose or to GATE_ORDER that leaves the two disagreeing fails here rather than reaching a +contributor as a promise the bot does not keep. +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools")) + +import pr_intake # noqa: E402 +from apply_governance import desired_labels # noqa: E402 + +REPO_ROOT = Path(__file__).resolve().parents[1] +CLAIM = "Comment on the issue to claim it before opening the pull request." +PRECEDENCE = "If a form, a template, or a bot comment disagrees with this section, this section wins." +# One key phrase per route after `bot`, in the order the block in CONTRIBUTING.md states them. +# `held` keys on "assigned to you" because that is how the verbatim block words it. +ROUTE_PHRASES = { + "roster": "project lead", + "engaged": "approval", + "record": "merged a pull request of yours", + "editorial": "editorial", + "held": "assigned to you", + "help_wanted": "help wanted", +} + + +def flat(text: str) -> str: + """Collapse line wrapping, so a sentence reads the same however the file wraps it.""" + return re.sub(r"\s+", " ", text) + + +def read(relative: str) -> str: + return flat((REPO_ROOT / relative).read_text(encoding="utf-8")) + + +def section(heading: str) -> str: + """The body of a `## ` section of CONTRIBUTING.md, up to the next `## ` heading.""" + raw = (REPO_ROOT / "CONTRIBUTING.md").read_text(encoding="utf-8") + start = raw.index(f"## {heading}\n") + end = raw.find("\n## ", start + 1) + return flat(raw[start:] if end == -1 else raw[start:end]) + + +def test_claim_phrase_is_in_contributing_readme_template_form_and_seed(): + for relative in ( + "CONTRIBUTING.md", + "README.md", + ".github/pull_request_template.md", + ".github/ISSUE_TEMPLATE/3-reference-implementation.yml", + ): + assert CLAIM in read(relative), relative + seed = next(label for label in desired_labels() if label.name == pr_intake.HELP_WANTED) + assert CLAIM in seed.description + + +def test_contributing_has_the_precedence_sentence(): + assert PRECEDENCE in section("How work gets accepted") + + +def test_contributing_routes_follow_gate_order(): + routes = pr_intake.GATE_ORDER + assert routes[0] == "bot" + assert set(routes[1:]) == set(ROUTE_PHRASES), "a route was added or renamed without its phrase" + body = section("How work gets accepted") + positions = [body.find(ROUTE_PHRASES[route]) for route in routes[1:]] + assert -1 not in positions, dict(zip(routes[1:], positions)) + assert positions == sorted(positions), dict(zip(routes[1:], positions)) + + +def test_what_we_need_no_longer_promises_no_triage(): + body = section("What We Need") + assert "without waiting on triage" not in body + assert "Comment on one to claim it, then open the pull request. The comment is the claim." in body diff --git a/tests/test_pull_request_template.py b/tests/test_pull_request_template.py index 86da6fca..a6589bdf 100644 --- a/tests/test_pull_request_template.py +++ b/tests/test_pull_request_template.py @@ -32,3 +32,19 @@ def test_unmodified_template_references_nothing_and_passes(): def test_contributing_explains_the_choice(): text = (REPO_ROOT / "CONTRIBUTING.md").read_text(encoding="utf-8") assert "`Contributes to` before one it only" in text + + +def test_template_checkbox_matches_both_editorial_checks_when_ticked(): + import pr_intake + from closing_choice import EDITORIAL + + line = next(line for line in TEMPLATE.splitlines() if "This is an editorial correction" in line) + assert line == ( + "- [ ] This is an editorial correction on the documentation lane (typo, grammar, link," + " formatting) with no change in meaning, so it needs no issue" + ) + ticked = line.replace("- [ ]", "- [x]", 1) + assert EDITORIAL.search(normalize(ticked)) + assert pr_intake.editorial_ticked(ticked) + assert not EDITORIAL.search(normalize(line)) + assert not pr_intake.editorial_ticked(line) diff --git a/tools/apply_governance.py b/tools/apply_governance.py index b8e3fc93..d96020be 100644 --- a/tools/apply_governance.py +++ b/tools/apply_governance.py @@ -301,7 +301,9 @@ def desired_labels() -> list[Label]: # Stock GitHub defaults. Not created by Step 2, but relied on by Step 12. Label( name="help wanted", color="008672", - description="Already accepted work. Safe to start without waiting on triage.", + description=( + "Already accepted work. Comment on the issue to claim it before opening the pull request." + ), ), Label(name="good first issue", color="7057ff", description="Good for newcomers"), ] From 5b5dc57f4e61d846701646cf50c2b256aec57e25 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 21:51:12 -0600 Subject: [PATCH 17/31] Name the whole documentation lane and triage volunteers in the intake text, and pin its numbers to the gate Signed-off-by: rocklambros --- .../3-reference-implementation.yml | 4 ++-- CONTRIBUTING.md | 13 ++++++------ README.md | 5 +++-- tests/test_intake_prose_guard.py | 21 +++++++++++++++++++ tests/test_pull_request_template.py | 6 ++---- tools/apply_governance.py | 4 ++-- 6 files changed, 37 insertions(+), 16 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/3-reference-implementation.yml b/.github/ISSUE_TEMPLATE/3-reference-implementation.yml index 11831429..88faa566 100644 --- a/.github/ISSUE_TEMPLATE/3-reference-implementation.yml +++ b/.github/ISSUE_TEMPLATE/3-reference-implementation.yml @@ -8,8 +8,8 @@ body: value: >- This is the work the project needs most. Check the open issues labeled `help wanted` before filing, since the one you want may already be accepted. - A `help wanted` issue is already accepted work. Comment on the issue to claim it - before opening the pull request. + Already accepted work. Comment on the issue to claim it before opening the pull + request. - type: input id: target attributes: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1f6e36d9..0f7734f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,12 +58,13 @@ quarter is not a finding without value. Anyone may open an issue. Only an issue carrying `status:accepted` enters the backlog. -If you are a project lead, a workstream lead, or a code owner, your pull requests are not -queued. A lead's approval, or a lead's request for changes within the last 30 days, clears -the queue. If a lead has approved and merged a pull request of yours before, your pull -requests are not queued. An editorial fix to a page under `docs/topics/` or `design/` (a -typo, a link, formatting, no change in meaning) needs no issue: tick the editorial box in -the pull request template, and keep every changed file on that lane. Otherwise the pull +If you are a project lead, a workstream lead, a triage volunteer, or a code owner, your +pull requests are not queued. A lead's approval, or a lead's request for changes within +the last 30 days, clears the queue. If a lead has approved and merged a pull request of +yours before, your pull requests are not queued. An editorial fix on the documentation +lane, a page under `docs/topics/` or `design/` or one of the root notice files (a typo, a +link, formatting, no change in meaning), needs no issue: tick the editorial box in the +pull request template, and keep every changed file on that lane. Otherwise the pull request needs an accepted issue that a lead assigned to you. Comment on the issue to claim it before opening the pull request. The comment is the claim, and a lead assigns you. An issue labeled `help wanted` is open to anyone who commented on it before opening the pull diff --git a/README.md b/README.md index 8893112b..57501fb3 100644 --- a/README.md +++ b/README.md @@ -349,8 +349,9 @@ Every issue starts from one of six forms. There are no blank issues. A form appl issue to `status:accepted`, and only an accepted issue enters the backlog. A pull request that changes behavior, alters normative text, or adds code needs an accepted issue that a lead assigned to you, or a `help wanted` issue. Comment on the issue to claim it before -opening the pull request. The comment is the claim. An editorial correction to a page -under `docs/topics/` or `design/`, a typo or a broken link, needs none. +opening the pull request. The comment is the claim. An editorial correction on the +documentation lane, a typo or a broken link in a page under `docs/topics/` or `design/` or +in one of the root notice files, needs none. ```mermaid flowchart LR diff --git a/tests/test_intake_prose_guard.py b/tests/test_intake_prose_guard.py index 21af9381..74322eb6 100644 --- a/tests/test_intake_prose_guard.py +++ b/tests/test_intake_prose_guard.py @@ -18,6 +18,8 @@ REPO_ROOT = Path(__file__).resolve().parents[1] CLAIM = "Comment on the issue to claim it before opening the pull request." PRECEDENCE = "If a form, a template, or a bot comment disagrees with this section, this section wins." +# The block spells the per-issue cap as a word, so the test maps the constant to it. +NUMBER_WORDS = {1: "one", 2: "two", 3: "three", 4: "four", 5: "five"} # One key phrase per route after `bot`, in the order the block in CONTRIBUTING.md states them. # `held` keys on "assigned to you" because that is how the verbatim block words it. ROUTE_PHRASES = { @@ -57,12 +59,29 @@ def test_claim_phrase_is_in_contributing_readme_template_form_and_seed(): assert CLAIM in read(relative), relative seed = next(label for label in desired_labels() if label.name == pr_intake.HELP_WANTED) assert CLAIM in seed.description + # The form carries the label's own description, word for word. + assert seed.description in read(".github/ISSUE_TEMPLATE/3-reference-implementation.yml") def test_contributing_has_the_precedence_sentence(): assert PRECEDENCE in section("How work gets accepted") +def test_editorial_sentence_names_the_whole_documentation_lane(): + # base_branch_guard.in_docs_lane admits the root notice files too. The precedence + # sentence makes this prose binding over the bot, so it must not name less. + body = section("How work gets accepted") + sentence = next(part for part in body.split(". ") if part.startswith("An editorial fix")) + assert "`docs/topics/`" in sentence and "`design/`" in sentence + assert "the root notice files" in sentence + + +def test_block_numbers_match_the_gate_constants(): + body = section("How work gets accepted") + assert f"within the last {pr_intake.FRESH_REVIEW_DAYS} days" in body + assert f"{NUMBER_WORDS[pr_intake.HELD_CAP]} open pull requests per person per issue" in body + + def test_contributing_routes_follow_gate_order(): routes = pr_intake.GATE_ORDER assert routes[0] == "bot" @@ -71,6 +90,8 @@ def test_contributing_routes_follow_gate_order(): positions = [body.find(ROUTE_PHRASES[route]) for route in routes[1:]] assert -1 not in positions, dict(zip(routes[1:], positions)) assert positions == sorted(positions), dict(zip(routes[1:], positions)) + # The roster route covers triage volunteers, so the roster sentence names them. + assert "a workstream lead, a triage volunteer, or a code owner" in body def test_what_we_need_no_longer_promises_no_triage(): diff --git a/tests/test_pull_request_template.py b/tests/test_pull_request_template.py index a6589bdf..f5faaa2d 100644 --- a/tests/test_pull_request_template.py +++ b/tests/test_pull_request_template.py @@ -7,7 +7,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools")) -from closing_choice import SECTION, evaluate, normalize, parse_references # noqa: E402 +import pr_intake # noqa: E402 +from closing_choice import EDITORIAL, SECTION, evaluate, normalize, parse_references # noqa: E402 REPO_ROOT = Path(__file__).resolve().parents[1] TEMPLATE = (REPO_ROOT / ".github" / "pull_request_template.md").read_text(encoding="utf-8") @@ -35,9 +36,6 @@ def test_contributing_explains_the_choice(): def test_template_checkbox_matches_both_editorial_checks_when_ticked(): - import pr_intake - from closing_choice import EDITORIAL - line = next(line for line in TEMPLATE.splitlines() if "This is an editorial correction" in line) assert line == ( "- [ ] This is an editorial correction on the documentation lane (typo, grammar, link," diff --git a/tools/apply_governance.py b/tools/apply_governance.py index d96020be..f12bba1f 100644 --- a/tools/apply_governance.py +++ b/tools/apply_governance.py @@ -325,8 +325,8 @@ def desired_issues() -> list[Issue]: Every seeded issue carries `scope:in-focus`, `status:accepted`, and `help wanted`. Those three labels are what make the acceptance gate CONTRIBUTING.md describes an - invitation rather than a queue: a contributor can open a pull request against one - without waiting on triage. The 7 follow-ups are real findings the plan surfaced + invitation rather than a queue: a contributor comments on one to claim it, then opens + a pull request against it. The 7 follow-ups are real findings the plan surfaced while writing itself, but none sit on the ninety-day serial chain, so they carry `scope:deferred` and wait for a maintainer like anything else filed from outside. From caeed80174dfe28ea397d1bb675662645a370b37 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 21:59:26 -0600 Subject: [PATCH 18/31] Record the intake gate design, its premortem, and its plan Signed-off-by: rocklambros --- design/2026-10-29-pr-intake-rules-design.md | 570 ++++++++++++++++++ .../2026-10-29-pr-intake-rules-premortem.md | 357 +++++++++++ design/plans/2026-10-29-pr-intake-rules.md | 290 +++++++++ 3 files changed, 1217 insertions(+) create mode 100644 design/2026-10-29-pr-intake-rules-design.md create mode 100644 design/2026-10-29-pr-intake-rules-premortem.md create mode 100644 design/plans/2026-10-29-pr-intake-rules.md diff --git a/design/2026-10-29-pr-intake-rules-design.md b/design/2026-10-29-pr-intake-rules-design.md new file mode 100644 index 00000000..ba96ef3a --- /dev/null +++ b/design/2026-10-29-pr-intake-rules-design.md @@ -0,0 +1,570 @@ +# PR intake: one gate for work no maintainer asked for + +Version 3.1, 2026-10-30. Owner: Rock Lambros. Status: design, rewritten after Rounds 2 to 5 of the premortem (`2026-10-29-pr-intake-rules-premortem.md`), amended after the plan premortem and the implementation (see Amendments). Tracking issue: issue #362. + +## Intent + +Maintainer attention is the scarce resource in this repository. The pull requests that +spend it without a maintainer having asked for the work pass the intake bot today, because +the bot checks one thing: does the body mention an issue carrying `status:accepted`. Any +contributor can satisfy that by copying a number from the public accepted list. The week of +2026-10-22 showed the shape: one author opened fifteen pull requests in one day, fourteen +declaring `Fixes` on accepted milestoned issues nobody had assigned and one declaring +`Part of` on an accepted umbrella. A one-line comment change to a source file (PR #354) +sits in the same queue with `Refs #92` and the editorial box ticked. + +Two earlier versions of this design were measured against the live repository by the +premortem and rewritten. Version 1 keyed on the closing keyword and the editorial box. +`Part of` was a one-word bypass and the box was never what let PR #354 in. Version 2 keyed +on who holds the work but let issue authorship and any review state count as a hold. The +first turned a bulk milestone pass into thirty standing pass tokens, the second let a +months-old "changes requested" review clear a pull request forever. Version 3 keys on facts +a lead performs or has performed, keeps the bot's own writes reversible, and returns to +today's behavior whenever the switch is off. + +## The gate + +A pull request is reviewed when a maintainer asked for the work it contains. The bot reads +that from facts, in this order, and passes on the first that holds. + +0. The author is Dependabot or the Actions bot. Out of scope: no label, no comment, no + health row. Both are reviewed by their own workflows. Any other `type: Bot` author is + evaluated as a non-roster author. +1. The author is on the trusted roster (`roadmap_model.trusted_logins`: project leads, + workstream leads, triage volunteers, CODEOWNERS logins). +2. A lead has engaged this pull request. Any one of: an `APPROVED` review that is not + dismissed, by a roster login whose `author_association` is `OWNER`, `MEMBER`, or + `COLLABORATOR`, a `CHANGES_REQUESTED` review by such a login within the last 30 days + (after 30 days of silence the pull request is "sent back, no response", a state the + health issue lists), or the queue label was removed by a roster login and the pull + request's head has not moved since (a hand clear, recorded on the data line). `COMMENTED` and + `DISMISSED` reviews do not count: a thread reply is not a request for the work. +3. The author's record: a pull request by this author was merged in this repository with + an `APPROVED` review from a roster login. The project already asked this person for + work once and took it. +4. The editorial box is ticked and every changed path is on the documentation lane + (`base_branch_guard.in_docs_lane`: `docs/topics/`, `design/`, and the root notice + files). One definition, shared with the lane that publishes to `main`. A tick on + `docs/spec/`, `docs/concepts/`, `specification/`, `reference-implementations/`, or any + source path queues with the paths named. +5. The author holds a referenced accepted issue: the pull request references (any shape + `closing_choice.parse_references` reads, closing or contributing, in title or body) an + open issue carrying `status:accepted` to which the author is assigned. A held issue + clears at most two open pull requests by the same author. The third queues naming the + two. Assignment is the only hold. An accepted issue's author does not hold it by + filing it. A lead who wants the filer to build it assigns them, which is one click at + acceptance. +6. The standing request: the pull request references an open accepted issue carrying + `help wanted` on which the author commented before opening the pull request. The label + is the project's prior consent, the comment is the claim, and a lead withdraws consent + by removing the label. + +Otherwise the pull request is queued. + +Drafts are not evaluated. They are re-evaluated on `ready_for_review`. + +## What the bot writes + +On queue: the label `status:needs-triage`. One comment. Auto-merge disabled. On clear: the +label removed and the comment edited to say so. On no change in facts: nothing. Every write +is one the bot can undo. + +The comment. One per pull request. The bot finds it by two facts, never by text alone: the +comment's `user.login` equals `roadmap_model.BOT_LOGIN` exactly, and the body carries the +marker ``. A legacy queue comment (a bot-authored comment beginning +"Thanks for this. It is queued rather than ignored.", or carrying ``. + +The comment reports every unmet route, in gate order, in the contributor's terms: + +- no accepted issue referenced: "This pull request references no open accepted issue. See + Current Priority Scope and `help wanted` for what the project has asked for." +- an accepted issue referenced and closed: "This pull request references #n, which was + accepted and is now closed. New work on it needs a new issue." +- accepted issues referenced and not held: "This pull request references #a and #b, which + are accepted, and neither is assigned to you." If the author has already commented on + one of them: "You asked for #a on . That claim is waiting on a lead." Otherwise: + "To claim one, comment on it. A lead may assign it. A lead's approval of this pull + request, or a changes-requested review, also clears the queue by the next nightly run." +- the editorial box on a path off the documentation lane: "The editorial box covers the + documentation lane (`docs/topics/`, `design/`, and the root notice files). These paths + are outside it: `path`, `path`. Fold the wording change into the next pull request that + changes the file, or leave it as a comment on the issue it concerns." +- a held issue already clearing two open pull requests: "#n already clears PR #a and PR #b." + +Every comment carries "This pull request is queued rather than ignored. Nothing here is +rejected." Where a closing keyword names an open milestoned issue, the merger's warning +that exists today is in the same comment. On a pull request opened before the rule landed, +the comment says the rule is new and that the 30-day clock starts today. + +Paths in the comment are wrapped in code spans (which suppress autolinks), truncated at +200 characters, at most twenty of them, and the whole comment is capped at 8,000 +characters. + +The label. The bot owns it: set when the gate holds, removed when it stops holding, left +alone when nothing changed. A hand removal by a roster login is itself a fact (gate 2), so +the bot does not fight a lead. A hand removal by anyone else is reversed on the next +evaluation, and the comment says why. + +Auto-merge. Disabled whenever the bot queues, and whenever auto-merge is on for a +non-roster author's pull request that closes an open milestoned issue, on every event, +whatever the gate decides. The +disable call is the one write whose failure is red even on an event run, because its +failure is the design's named harm. Before enforcement a lead rehearses it on a throwaway +pull request with auto-merge enabled and the tool asserts `autoMergeRequest` is null +afterwards. If the mutation needs more than `pull-requests: write`, convert-to-draft is +the fallback and the rehearsal records which path works. + +## Where the gate runs + +Triggers: `pull_request_target` on `opened`, `edited` (skipped only when none of body, +title, or base changed), `reopened`, `ready_for_review`, `unlabeled`, `auto_merge_enabled`, +and `synchronize` (on which the tool re-evaluates at once only when the editorial box is +ticked. A push also lapses a hand clear, which the nightly sweep picks up). +`pull_request_review` on `submitted` and `dismissed`. It also runs on `schedule` at 03:10 +UTC, before the 04:10 roadmap sweep so the health issue reports the same night's pass, and +on `workflow_dispatch` with inputs `pr_number` (optional), `mode` (`evaluate`, the default, +`dryrun`, or `retract`), and `authors` (`all`, the default, or `bots`). + +Checkout. The job checks out the pull request's base ref on pull request events and the +default branch on schedule and dispatch, with `persist-credentials: false`. The job's `if` +admits pull request events only against `main`, `integration`, or `release/*`, the +ruleset-protected bases. The +pull request head never reaches the checkout. Version 2 checked out `main` and the +premortem measured the cost: promotions after other people's merges have waited 7 to 17 +days, during which intake would have been silently off. This is a change from the +closing-choice check's `main` checkout, recorded for the owner. + +Modes. Two repository variables arm enforcement: `PR_INTAKE_ENFORCED=true` and +`PR_INTAKE_TOOL_SHA` equal to the tool SHA, a digest over the git blob SHAs of the five +modules the gate runs, at the checked-out ref. When either is missing or mismatched the +tool runs in legacy mode: on event runs only, it queues a human author's pull request +that references no accepted issue, carries no editorial tick, and has no bot comment yet, +which are today's exits, and it prints what the gate would do for everything else. A +legacy sweep writes nothing. Every tool change produces a new SHA, so a changed tool runs +in legacy mode until a lead dry-runs it and re-arms, and a sweep that finds enforcement +requested but the SHA mismatched is red. + +Retract. `mode=retract` removes the queue label from every open pull request whose bot +comment carries the marker (or, with `authors=bots`, from every open pull request by +Dependabot or the Actions bot), edits the comment to one line ("Retracted. This pull +request is not queued."), and prints the summary. It refuses while enforcement is armed. +This is the undo for a wrong sweep. + +Event runs exit 0 on a read failure and print a notice. Nothing is written from partial +facts. Scheduled and dispatched runs are attended: every pull request is evaluated, a +failed evaluation goes into a failed list, and the run fails after the loop when the list +is non-empty or a shared listing failed, with one `::error::` line per failed pull request +(number and failure class) and a summary line that opens with the mode and the tool SHA +and counts evaluated, newly queued, queued total, cleared, unchanged, skipped (changed +during the sweep or past the budget), out of scope, errors, writes, calls, and rate limit +remaining. The same goes to the step summary, with one line per pull request in every +mode. + +Pacing. Writes are paced at one per second. A `Retry-After` of up to 60 seconds is honored +once per write. A longer one fails the write, and a primary rate limit stops the write phase. +Writes happen only when the label state or the rendered comment differs from what is +there. The auto-merge call is skipped when `auto_merge` is already null. The comment edit +happens before the label change so an interrupted run leaves an explanation. Before any +write the sweep re-reads the pull request's `updated_at` and skips it, counted, when it +moved since the facts were read. All listings paginate (`--paginate --slurp`), including +files, reviews, and comments per pull request. A files listing of 3,000 entries, or shorter +than `changed_files`, is truncated and queues with that reason. A listing that fails to +arrive is an evaluation error, never a fact with a default. + +Concurrency: `group: ${{ github.event.pull_request.number && format('pr-intake-{0}', +github.event.pull_request.number) || inputs.pr_number && format('pr-intake-{0}', +inputs.pr_number) || 'pr-intake-sweep' }}`, `cancel-in-progress` only on pull request +events. + +Batching. On any sweep, an author with more than three newly queued pull +requests gets the full comment on the newest and a one-line comment on the rest ("Queued +for the reason given on PR #n."), each still carrying its data line. + +## Where the logic lives + +`tools/pr_intake.py`, in the shape of `closing_choice.py`: a `Facts` record (number, +state, draft, author, author type, title, body, base, head SHA, created and updated times, +labels, changed paths with truncation flag, referenced numbers with each one's kind (issue +or pull request), state, labels, assignees, and whether the author commented on it, +reviews with state, login, association, commit, submitted time, the last removal of the +queue label and who did it, the bot comment and its data line, auto-merge state, the +author's merged-and-approved record, roster, and mode) and a pure `decide(facts) -> Decision` +returning the gate outcome, the unmet routes, the label action (`set`, `clear`, `keep`), +the comment text and data line, and the auto-merge action. `main()` collects facts through +`tools/gh_runner.py` and applies the decision. + +`tools/gh_runner.py` is `fetch_roadmap._runner` lifted into a module both this tool and +`roadmap_sync.py` use: absolute `/usr/bin/gh`, a minimal environment (`PATH`, a fresh +`HOME`, `GH_TOKEN`, `GH_HOST`, `GH_PROMPT_DISABLED=1`, `GH_NO_UPDATE_NOTIFIER=1`), a +`--gh` flag for tests, and an allowlist of argv shapes, one pattern per operation (the +reads, `api -X PATCH repos//issues/comments/ --input -`, `api -X POST +repos//issues//comments --input -`, `api -X POST +repos//issues//labels --input -`, `api -X DELETE +repos//issues//labels/status%3Aneeds-triage`, `pr merge +--disable-auto --repo `, and the draft fallback `pr ready --undo --repo +`). Every body goes to stdin as JSON. `PR_NUMBER` matches +`^[0-9]{1,9}$`. `gh` stderr is never echoed. A failure prints its class. Nothing from the +pull request reaches argv or a workflow command. + +References inside an HTML anchor whose target is outside this repository are dropped +before parsing, so a Dependabot changelog's `#146` is not this repository's #146. + +`pr-intake.yml` becomes: the job, named, with `contents: read`, `pull-requests: write`, +`issues: read`, each line commented. Checkout as above. One step that runs +`python3 -I -S tools/pr_intake.py` with the event's number, the dispatch inputs, and the +two variables in `env`. The `zizmor` ignore comment says what `closing-choice.yml`'s +says: the job checks out a maintainer branch, never the pull request head. + +`monitor_roadmap.py` gains one check: the conclusion of the last scheduled intake run. On +a change it comments on the health issue mentioning every lead, as it does for the +roadmap monitor today (decision 17), so a red nightly sweep reaches more than the one +mailbox GitHub emails. + +The closing-choice check is unchanged. A code-path editorial tick is queued, not failed. +This keeps the 2026-09-09 governance decision not to make the box a red check, and the +premortem found the owner's own PR #357 would have failed the red check. It is a deviation +from the owner's 2026-10-29 request. The owner writes one line here, "Deviation accepted" +or "Overridden", before the implementing pull request opens. + +## The health issue + +`roadmap_sync.py` gains a section, "Queued pull requests", rendered from the bot's own data +lines (read only from comments whose `user.login` is `BOT_LOGIN` exactly), with the +invariant the report already keeps: numbers, reason codes, and days, no contributor +login, no fetched text. Grouped by reason, with one search link for the per-author view. +Under it: "Claims waiting on a lead" (an open accepted issue referenced by a queued pull +request on which that author has commented, with the claim's age), "Queued more than 30 +days" (the clock starts at the first sweep for pull requests opened before the rule), +"Awaiting re-review", "Sent back, no response" (changes requested, 30 days silent), and +"Labeled without a gate record", and three counts: median age of queued pull requests, +pull requests a lead cleared by hand that merged in the last 60 days, and non-roster pull +requests merged in the last 60 days against the 60 days before. A project lead records +the baselines in the `PR_INTAKE_BASELINES` repository variable the morning after +enforcement, and the section names the review date 60 days later. The intake sweep's last +conclusion and run link sit at the top of the section. If claims waiting rise or +non-roster merges fall against the prior 60 days, the switch goes back to legacy mode. +That is the failure condition, written now. + +## Documentation + +CONTRIBUTING.md, "How work gets accepted": the gate in the contributor's words, each route +one sentence, and "If a form, a template, or a bot comment disagrees with this section, +this section wins." + +CONTRIBUTING.md, "What We Need": `help wanted` issues are the project's standing request. +Comment on one to claim it, then open the pull request. The comment is the claim. "You can +open a pull request against it without waiting on triage" is removed, as is the line at +73-74 that says the same. + +`.github/PULL_REQUEST_TEMPLATE.md`: the editorial checkbox reads "This is an editorial +correction on the documentation lane (typo, grammar, link, formatting) with no change in +meaning, so it needs no issue", and the HTML comment above the issue section no longer says +"open the PR anyway. It will wait rather than be closed" without the claim step. + +README.md: the paragraph and the mermaid flow under "How work gets accepted" gain the claim +step. `.github/ISSUE_TEMPLATE/3-reference-implementation.yml`: the line pointing at `help +wanted` says to comment first. + +GOVERNANCE.md, "Triage authority": a lead or triage volunteer accepts a claim by assigning +the claimant. GitHub permits assigning a non-collaborator only after they comment. Under +"How leadership changes": a roster entry also exempts that login's pull requests from +intake and lets their approval clear another's, so it is added only for people who review +here. A GOVERNANCE/CODEOWNERS follow-up reconciles the roster with repository access +(three workstream leads hold no access today) before enforcement. + +## Goals + +- A non-roster pull request is reviewed when a lead asked for the work, read from a lead's + review, a lead's hand clear, the author's merged record, an assignment, or a claim on a + `help wanted` issue. +- The queue label is true: set and cleared by the bot from facts, never stale for longer + than a night, listed nightly on the health issue, retractable in one dispatch. +- One bot comment per pull request, edited in place, every unmet route named. +- Off means today's behavior. +- Every promise has a test, listed below. +- Three outcome measures on the health issue with their baselines, and a written failure + condition. + +## Non-goals + +- No cap on open pull requests per author (the per-issue cap of two is a different thing). +- No change to how an issue becomes accepted, to the labels, or to the milestones. +- No change to the closing-choice check. +- No automatic closing of any pull request. +- Dependabot and the Actions bot are out of the gate's scope. + +## Anti-goals + +- A rule that depends on a human noticing. +- Blocking a lead's own pull request, or one a lead has approved. +- A red required check on an honest editorial declaration. +- A bot comment that reads as a rejection, promises an action nobody performs, or names a + contributor in a governance record. +- A write the bot cannot undo. + +## Constraints + +- `pull_request_target` with `permissions: {}` at the top. The pull request head is never + checked out. No `${{ }}` in any `run:` in any job, and zizmor passes. +- `tools/pr_intake.py` and `tools/gh_runner.py` run under `python3 -I -S`. +- CODEOWNERS: `/.github/`, `/tools/`, `/tests/` need a project lead's approval. The + implementing pull request carries one non-author approval. +- `monitor_roadmap.py` and the roadmap sweep need `actions: read` to list the intake + sweep's runs. The monitor job and the roadmap sweep's two jobs gain that permission and + no other. +- Prose follows STYLE.md. + +## Tests + +- `tests/test_pr_intake.py`, `decide`: one case per gate route passing. Roster login + without a repository role does not clear by review. `COMMENTED` and `DISMISSED` do not + clear. `CHANGES_REQUESTED` at 29 days clears and at 31 days does not. Hand clear by a + roster login holds while the head is unchanged, by a non-roster login is reversed. The merged + record passes. Editorial on the lane passes, off the lane queues naming the paths. + Held issue passes, third pull request on one held issue queues naming the two. Issue + author without assignment queues. `help wanted` with a prior comment passes, without + one queues. Closed accepted reference gets its own reason. Several unheld references + render as a list. A body failing two routes renders both. Drafts and bots produce no + action. Truncated files listing queues. Legacy comment adopted (one edit, no post). + Unchanged facts produce zero writes. Legacy mode writes only today's queue on an event + run, and a legacy sweep writes nothing. A tool SHA mismatch means legacy mode. Retract removes and edits. Auto-merge + disabled on queue and on the state case. A null review user and a `Bot` reviewer are + skipped. A reference to a pull request number is not an issue. A changelog anchor is + dropped. +- `tests/test_gh_runner.py`: a hostile fixture (title, body, paths, logins with `@`, + leading `-`, `../`, newlines) produces byte-identical argv to the expected list. Any + argv outside the allowlist raises. Bodies go to stdin. +- `tests/test_pr_intake_workflow.py`: the triggers. The `edited` guard with base. The + checkout rule. `contents: read`. The concurrency expression. The cron and its position + before 04:10. `${{ }}` absent from every job. The sweep evaluates 35 stubbed pull + requests with `--paginate` on the listing. A sweep with one failed evaluation is red + with its `::error::` line and the summary. An event run with a read failure exits 0 and + writes nothing. The stub serves recorded real responses from `tests/fixtures/` for + files (two pages), reviews, comments (REST login form), the pulls listing, and the + `--slurp` shape. +- `tests/test_roadmap_sync_health.py`: the queued section renders numbers, reasons, and + days only (a queued pull request by `outsider` leaves no login). A data line in a + comment by anyone but the bot is ignored. The 30-day, sent-back, and claimed headings. + The three counts. +- `tests/test_monitor_roadmap.py`: the intake conclusion check and its comment on change. +- A test ticks the template's own checkbox line and asserts `closing_choice.EDITORIAL` + matches it, and does not match the unticked line. A guard that README's paragraph and + the template's comment carry the claim phrase CONTRIBUTING uses. + +## Rollout + +1. Merge to `integration` with one non-author approval. The pull request says `Part of + #362`, so the merge does not close the tracking issue. The new workflow runs at once on + pull requests against `integration` (base-ref checkout) in legacy mode, which is today's + behavior. +2. Precondition for what follows: the GOVERNANCE/CODEOWNERS reconciliation is merged, or a + project lead writes on issue #362 why the three roster entries without repository access + stay. +3. Dispatch `mode=dryrun` with the variables unset and read the would-queue list and the + tool SHA it prints. +4. Leads clear the collateral by the gate's own facts, using that list: approve, request + changes afresh, or assign, on every pull request a lead has already engaged (PR #24, + PR #112, PR #113, PR #297, PR #253, PR #255, and PR #259) and on any first-time + contributor whose work the project wants. Done when a second dry run lists none of + those, or a project lead writes "proceed with the remainder" + on issue #362. +5. Rehearse the auto-merge disable on a throwaway pull request. +6. Promote, so pull requests against `main` run the same workflow and the health issue and + the monitor report on it. +7. Edit the live `help wanted` label description, retract the queue label from the bots' + pull requests, then set `PR_INTAKE_TOOL_SHA` and `PR_INTAKE_ENFORCED=true` and dispatch + the live sweep outside the 03:00 to 04:30 UTC window. +8. The next morning, record `PR_INTAKE_BASELINES` from the health issue. +9. A project lead closes issue #362 by hand. + +The checklist on issue #362 carries each step's actor and expected result. Undo at any +step: unset `PR_INTAKE_ENFORCED`, then dispatch `mode=retract`. + +## Risks and how they are held + +- The roster trusts login strings, three of them without repository access. Gate 2 adds + the `author_association` check for reviews, and the reconciliation follow-up precedes + enforcement. Gate 1 (author) still trusts the roster. Accepted, and the GOVERNANCE + sentence says what a roster entry now grants. +- A roster that fails to parse trusts nobody. The tool prints a warning and writes nothing. +- A held issue can clear two unrelated pull requests. Accepted at two. The health issue + shows held issues with open pull requests. +- Claims on issues have no responder today (one in four answered, measured). The comment + no longer promises one. The health issue lists claims older than 14 days as a lead + agenda item. Issue #145's first-response target is the real fix and stays open. +- A runner image update changes a `gh` shape. The recorded fixtures do not catch it. The + weekly legacy-mode sweep's "facts read empty for a pull request the listing says has + data" check does, red. +- The base-ref checkout trusts the ruleset of the base it runs from. `main`, + `integration`, and `release/*` are ruleset-protected. Other branches in this repository + are not (26 when the plan premortem counted), and a pull request can target any of + them, so the job's `if` admits only the three. Each protected base requires one + code-owner approval, and an admin can bypass each one. Recorded for the owner. + +## Amendments + +These amend version 3.0. The first group comes from the plan premortem +(`2026-10-29-pr-intake-rules-premortem.md`, "Plan premortem"). The second group records +rulings made during implementation. Where a body sentence above contradicted one of them, +the sentence was changed to match. + +### From the plan premortem + +Auto-merge keyed on state. The bot turns auto-merge off whenever it is on for a non-roster +author's pull request that closes an open milestoned issue, on every event including +`auto_merge_enabled`, whatever the gate decides. Version 3.0 keyed the rule on an edit +that adds a closing keyword, which needs a before-and-after body the tool never has and +misses the dangerous moment, the enable. The trade is that a non-roster pull request +closing a roadmap issue never auto-merges. The comment says so, and a lead merges it by +hand. + +Hand-clear head record. A hand clear holds while the pull request's head is the one the +bot recorded on the data line when it first saw the roster unlabel. The `unlabeled` +trigger makes that first sight immediate. Version 3.0 read the last push from the +timeline, whose commit dates the pusher sets, so a clear could outlive several pushes. + +Help-wanted cap. The `help wanted` route clears at most two open pull requests per author +per issue, the same cap as assignment. Uncapped, one comment on one issue cleared all +fifteen of one contributor's pull requests in the premortem's replay. + +Editorial-first ordering. An editorial tick with any changed path off the documentation +lane queues as `editorial_off_lane` before the issue routes are read. Read in plain gate +order, the motivating pull request, PR #354, passed through the `help wanted` route on a +dogfooding comment. + +`awaiting_rereview` and `paths_truncated`. A changes-requested review older than 30 days, +with no newer roster review, queues as `awaiting_rereview` when the author pushed or +commented after it, and as `sent_back` only when the author has been silent. A files +listing of 3,000 entries, or one shorter than the pull request's changed-file count, +queues as `paths_truncated` and never passes as editorial. Version 3.0 charged a lead's +silence to the contributor and had no reason code for a truncated listing. + +Sweep budget. A sweep drops bots and drafts from the listing before reading anything, +resolves references against the shared listings instead of reading each issue, skips a +pull request whose facts have not moved since the bot last wrote, and reads the rate limit +every ten evaluations. Reading stops 150 calls short of a 600-call budget, or when fewer +than 150 calls remain in the hourly limit, so the writes for what was read still fit. The +rest are counted as skipped and the run is red. The job times out after twenty minutes. +Unbounded, the nightly sweep could spend most of the hourly limit in the roadmap sweep's +hour, and one body naming many numbers could spend it alone. + +Base-ref `if`. Pull request events run the job only when the base is `main`, +`integration`, or `release/*`. The checkout trusts the base's ruleset, and those three are +the bases that have one. + +Honest base-ref sentence in Risks. The Risks section now says which bases are protected +and that the others are not. The version 3.0 sentence implied every base ref was +protected. + +Baselines variable. The outcome baselines live in the `PR_INTAKE_BASELINES` repository +variable, set by a project lead the morning after enforcement, and the health section +names a review date 60 days later. The counts compare 60-day windows, and hand clears are +counted from the route on the data line. The `pull_request_review` trigger serves the same +measure: approved non-roster pull requests merge within hours of approval, so a review +that cleared only at the nightly sweep would have counted the designed path as a false +queue. Version 3.0's baselines could not be read from anything the tool wrote. + +Dry-run mode. `mode=dryrun` runs the enforced logic, writes nothing, prints every +decision, and prints the value that arms the tool. Legacy mode writes only on event runs, +and a legacy sweep writes nothing. The tool SHA covers the five modules the gate runs, and +a sweep that finds enforcement requested with a mismatched SHA is red, with the remedy in +its error line. In version 3.0 the rollout's dry sweep ran in legacy mode, which wrote, and +a tool change de-armed the gate with every signal green. + +Retract order. Retract refuses while enforcement is armed. The undo at any step is to +unset `PR_INTAKE_ENFORCED` first, then dispatch `mode=retract`, so the gate cannot +re-queue what the retraction cleared. With `authors=bots`, retract clears the queue label +inherited by Dependabot pull requests, which carry no gate record. + +`Part of #362`. The implementing pull request names the tracking issue with `Part of`, so +its merge to `integration` does not close issue #362. A project lead closes the issue by +hand once the rollout is done. A closing keyword would have closed it at the first rollout +step. + +### From the implementation + +Known bots. Only Dependabot and the Actions bot are out of the gate's scope, together with +drafts, in both modes. Any other `type: Bot` author, a GitHub App for instance, is +evaluated as a non-roster author. Scoping out every bot would have let any app pass a gate +that queues it today. + +Facts hash. The hash that decides whether anything changed covers the pull request's +creation time, the recorded hand-clear head, and whether each reference is an issue or a +pull request. It leaves out the state the bot owns, the queue label and the bot's own +unlabel events, instead of predicting it. The hash detects a change in facts a person +performed, and the bot's writes are not such facts. + +Repeat hand clears. A roster unlabel records both the head and the unlabel time on the +data line. After a push lapses a clear, a second roster unlabel newer than the recorded +time counts as a fresh clear. Without the time, a lead could clear a pull request once. + +Auto-merge call budget. The auto-merge write is exempt from the call budget, because its +failure is the harm the design names. Disabling auto-merge on every queued non-roster pull +request is already in the body and is not restated here. + +Reference caps. A pull request's referenced issues are read up to twenty, and claim +comments on up to five of them, and the bot comment says when a list was cut. A draft +takes no slot in the cap of two open pull requests per author per issue, which binds the +assignment and `help wanted` routes alike. Per-reference reads let one body spend the +budget, and a slot held by a draft, which the gate never evaluates, would queue a real +pull request. + +Author record. The merged-and-approved record is not read for roster, bot, or draft +authors. The gate never reaches that route for them, and the read costs one call per +merged pull request. + +Closing keywords. When a number appears twice with different keywords, the strongest +wins, because GitHub closes the issue on merge in that case. + +Pacing. A `Retry-After` is honored up to 60 seconds, and a longer one fails the write. The +write phase stops on a primary rate limit, since that window is far longer than any wait +worth holding a job for. A queue label whose write failed is re-applied by the next +nightly sweep. + +Seen. A pull request counts as seen from the bot's write time plus 120 seconds, extended to +the newest `updated_at` among the accepted issues it references. A lead's assignment after +the bot wrote is therefore a new fact, and the sweep reads the pull request again. + +Expiry. A pass that rests on a review carries an expiry, and the nightly sweep +re-evaluates the pull request once it is past. Without it, an unchanged pull request would +keep a 30-day pass forever. + +Fork reviews. A `pull_request_review` run on a fork's pull request holds a read-only +token, so an approval on a fork clears the label at the nightly sweep, not within the hour. +Same-repository pull requests clear on the review event. + +Schedule and modes. The nightly sweep runs at 03:10 UTC, and dispatch takes `evaluate`, +`dryrun`, or `retract`, with `authors` set to `all` or `bots`, as the plan ordered. Running +before the 04:10 roadmap sweep is best effort, since scheduled runs on this repository +have started up to two hours late. + +Health section. "Hand-cleared" counts only pull requests merged in the window. The median +age covers rows with a readable gate record. Bot-authored merges are left out of the +non-roster merge counts. The closed pull request listing is read newest first, page by +page, and stops once a page's oldest update is older than 120 days. Past ten pages the +whole section degrades instead of reporting a partial count. Comments are read only for +pull requests merged in the last 60 days, the only ones the counts use. A queue comment in +the earlier format is counted and listed on its own line. A baseline date far in the +future reads as not recorded, so a typo cannot stop the health write. One plan deviation: +`intake_recent` is not in the roadmap report's normalization exclusion list, because it is +not a report key and a dead entry misleads the next reader. + +Monitor signature. The monitor's `evaluate` keeps its positional parameters and takes the +run reader as a trailing keyword with a default. The plan's keyword-only form would have +broken every existing caller for no behavior the task needed. This is a plan deviation. + +Contributor text. The CONTRIBUTING editorial sentence names the documentation lane as +CONTRIBUTING defines it, `docs/topics/`, `design/`, and the root notice files, because the +gate admits the root files and the section's precedence sentence would otherwise bind the +narrower rule. The roster sentence names triage volunteers with the leads and code owners. +The key phrase is "assigned to you". The CODEOWNERS exemption sentence spans two comment +lines to stay inside the header's width. + +Draft until the owner's line. The implementing pull request opens as a draft and is marked +ready for review once the owner writes the line in "Where the logic lives". This replaces +"before the implementing pull request opens" in that paragraph, which is left as the owner +wrote it. The live `help wanted` label description changes at rollout, not at merge, +because the label is repository state the merge does not touch. diff --git a/design/2026-10-29-pr-intake-rules-premortem.md b/design/2026-10-29-pr-intake-rules-premortem.md new file mode 100644 index 00000000..92e5216d --- /dev/null +++ b/design/2026-10-29-pr-intake-rules-premortem.md @@ -0,0 +1,357 @@ +# Adversarial premortem: PR intake rules design + +Artifact: `design/2026-10-29-pr-intake-rules-design.md` v1.1 (Round 1), then v2.0 (Rounds 2 to 5). Date: 2026-10-29. Orchestrated as a multi-subagent premortem. Each perspective read the artifacts and the live repository independently. + +Round 1 was run against v1.1 and found that the design's two rules did not reach the outcome stated in its intent. The design was rewritten as v2.0 before Rounds 2 to 5, which attack v2.0. The Round 1 record is kept in full because the measurements that moved the design are in it. + +## Round 1. Problem framing and inputs (against v1.1) + +Perspectives spawned: Data Scientist (lead), Governance / Risk, Red Teamer, ML Engineer. Skipped: Security Architect and MLOps / SRE, because the framing layer had no trust-boundary or operational surface anchored. They lead Rounds 3 and 4. + +Premortem narrative. Six months out, the fifteen pull requests that motivated the rule are still in the review queue, now saying `Part of` instead of `Fixes`, and twenty newer ones from other authors say the same. The one-line comment fix the editorial rule was written for was merged in a week after its author unticked the box and left `Refs #92` in place. Thirty pull requests carry `status:needs-triage`, six of them approved by a workstream lead, two of them from contributors who filed the issue they were fixing, and nobody on the weekly call can see the list because the health issue lists issues only. A contributor who followed the "start from help wanted" paragraph got a bot comment telling them to ask permission. The design document naming one contributor by full name as the reason for the rule has been on `main` since November. + +Findings, surviving at Plausible or above. + +1. The claim rule keys on the closing keyword, and `Part of` is a one-word bypass the design protects as an anti-goal. + - Impact: Critical. Rule 2 is most of the design and lands without changing review load. + - Confidence: Very likely (prior Very likely from three perspectives. The Data Scientist and Governance measurements are independent of each other: fifteen non-roster open pull requests already use `Part of`, `Refs`, or `Contributes to` against the same accepted issues, and the fifteen-PR author's own PR #337 is one of them). + - Evidence anchor: design v1.1 "Anti-goals: Queuing a pull request for a `Part of`, `Refs`, or `Contributes to` reference. Those close nothing." Live PR #337: "Part of #92 / Fixes #336". + - Failure mode: the queued authors edit `Fixes` to `Part of`. The accepted-issue gate passes them (an accepted issue is referenced), the claim step is silent by design, and the review queue is unchanged. The project has also trained contributors that `Part of` is the spelling that avoids a queue. + - Blind spot: "declares Fixes" and "unrequested work" coincided in one week's sample. + - Opportunity cost: a CODEOWNERS-gated change to `.github/` and `tools/` for no reduction in queue size. + - Cross-attack: the Governance counterargument (forcing `Part of` is a win for roadmap accounting, since `assess` marks a contributing merge unverified) was weighed and held insufficient: the intent names review attention as the harm, and a `Part of` pull request demands the same review. Posterior stays Very likely. + +2. Assignment is not where this project records claims. 9 of 94 open milestoned issues carry any assignee. 3 of the 4 merged non-roster pull requests that closed a milestoned issue were unassigned. GitHub refuses to assign a non-collaborator who has not commented on the issue, so "one action" is three actions across two people and two objects. + - Impact: High. The rule is a blanket hold on every non-roster pull request, not a classifier. + - Confidence: Very likely (measured on the live listing by two perspectives independently). + - Evidence anchor: design v1.1 "which is one action in the GitHub interface". `GET /repos/.../assignees/` returns 404 for every author the sweep would queue. + - Failure mode: every contributor pull request on roadmap work waits for a three-step hand clearance, and claim requests on issues become the new flood with no stated decline rule. + - Blind spot: assignment was treated as a scarce deliberate act. Here it is an unused field. + - Opportunity cost: maintainer attention moves from reviewing pull requests to answering claim comments and clearing labels. + +3. Rule 1 does not catch its own example. PR #354 says `Refs #92`, #92 is accepted, and the intake gate passes any mention of an accepted issue. Untick the box and both bots pass. + - Impact: High. + - Confidence: Very likely (traced through the live body and both code paths by three perspectives). + - Evidence anchor: `pr-intake.yml` lines 39 and 58-63 (`NUMBERS` is every `#n` in the body. The first accepted one exits 0). Design v1.1 "no issue, the editorial box ticked" is false on the data. + - Failure mode: a contributor learns "Refs an accepted issue, untick the box". The design claims a closed door. + - Blind spot: the exemption's only function is to waive the accepted-issue requirement. A pull request that already names an accepted issue gains nothing from it. + - Opportunity cost: a red required check on honest declarations with zero attention saved on the example. + +4. The red required check on an honest editorial declaration reverses a settled decision and would have failed the project lead's own PR #357. + - Impact: Medium. + - Confidence: Likely (the 2026-09-09 governance design rejected a red check for this box in so many words. PR #357 is a measured case. PR #323's editorial tick on `docs/spec/conformance.md` passes). + - Evidence anchor: `design/2026-09-09-contribution-governance-design.md` lines 390-406 ("turning it into a red required check would make the gate feel like a rejection"). Design v1.1 anti-goal "Blocking a lead's or a triage volunteer's own pull request". + - Failure mode: leads untick the box on their own prose fixes, contributors copy them, and the box stops meaning anything. + - Blind spot: the trade was made by naming examples, not by running the rule over merged history (one true positive, one lead false positive). + - Opportunity cost: a governance event per typo. + +5. The prose definition is wrong in both directions. A file suffix admits GOVERNANCE.md (the roster Rule 2 trusts), `.github/pull_request_template.md` (the sentence both regexes match), 23 normative spec pages, and eight reference-implementation READMEs. It rejects 273 schema description strings, 33 nav titles, seven issue-template YAMLs, the landing copy, and both LICENSE files. The definition also contradicts itself on `.github/`. + - Impact: Medium. + - Confidence: Very likely for the misclassification (it is the definition). Plausible for frequency. + - Evidence anchor: design v1.1 "A changed path is prose when its file name ends in `.md` ... Everything else is code ... including every file under ... `.github/`". `roadmap_model.py` line 203 reads GOVERNANCE.md as the roster. + - Failure mode: a template edit that pre-ticks the box makes every later pull request editorial. A `MUST` to `SHOULD` edit in `docs/spec/` is editorial by suffix. + - Blind spot: "published prose" was operationalized as a format. + - Opportunity cost: the exemption covers the files that decide what the bots do. + +6. "Open and milestoned" is not "accepted": 19 of 94 open milestoned issues lack `status:accepted` (deferred v0.2.0 issues, one blocked), and GOVERNANCE.md's "the two mean the same thing" is refuted by the data. A pull request on one of those gets two queue comments. + - Impact: Medium. + - Confidence: Very likely (measured). + - Evidence anchor: GOVERNANCE.md lines 39-41. Design v1.1 "the definition the existing 'Flag closing keywords' step uses". + - Failure mode: two definitions of roadmap inside one workflow. The "one comment, not two" promise breaks on the rarest path. + - Blind spot: the equivalence was checked in the write direction only. + +7. The queue label is a one-way ratchet with no owner, no drain, and no agenda line. 53 label events in 120 pull requests, 16 removals, all by one person. Dependabot pull requests have carried it since September. The project lead's own PR #355 went stale 48 minutes after the issue was accepted. The health issue lists issues only. A lead who removes the label by hand has it re-added by the next edit or sweep. + - Impact: High. + - Confidence: Very likely (timeline data and `roadmap_sync.py` lines 594 and 612 both say so). + - Evidence anchor: design v1.1 "The bot never removes the label". The dispatch sweep can add and never remove. + - Failure mode: within weeks the label means nothing, leads stop reading it, and the one promise in the bot comment ("a lead clears the queue label here") is broken by default. + - Blind spot: "a lead removes it" was read as a process. It is one person's habit. + - Opportunity cost: the signal the whole design produces decays to noise. + +8. The retroactive sweep queues six pull requests from five contributors who followed the rules in force: PR #253 (approved by Lance Dye), PR #24 (changes requested by Rock, three human comments), PR #291 and PR #278 (authors fixing the issue they filed), PR #259, PR #255. + - Impact: High. These are the "good contributor driven off" cases, identifiable by name. + - Confidence: Very likely (the sweep simulation ran the repository's own parser and roster against live bodies). + - Evidence anchor: design v1.1 names only the fifteen as the sweep's target. + - Failure mode: a bot tells an approved contributor to go ask for the issue. A bug reporter is told to ask permission to fix the bug they reported. + - Blind spot: the sweep was sized against one author. + +9. `help wanted` is the project's recruiting path, every such issue is milestoned, and the rule queues its takers while CONTRIBUTING.md ("you can open a pull request against it without waiting on triage") and the existing queue comment still point at the label. + - Impact: High. + - Confidence: Very likely (both texts quoted. Five of six open help-wanted issues unassigned). + - Evidence anchor: CONTRIBUTING.md "What We Need". `pr-intake.yml` line 68. + - Failure mode: two project texts send a first-timer in a loop. CONTRIBUTING's own precedence rule then says the bot is wrong. + - Blind spot: the design edited the paragraph that states the rule, not the one contributors land on. + +10. A late body edit after approval closes a roadmap issue through auto-merge, because the label is advisory, auto-merge is enabled, approvals are dismissed only by pushes, and the squash message is the body. + - Impact: High. This is the exact outcome the design exists to stop. + - Confidence: Plausible (every precondition verified live: `allow_auto_merge: true`, `squash_merge_commit_message: PR_BODY`, ruleset requires no label. The unknown is whether anyone tries). + - Evidence anchor: design v1.1 "The bots label and comment. People close." + - Failure mode: open with `Part of`, collect an approval, maintainer enables auto-merge, author edits to `Fixes`. Intake labels, closing-choice passes, GitHub closes the issue and the roadmap counts it delivered. + - Blind spot: "people close" assumes a person at merge time. + +11. The retrofit creates implementation hazards the design does not specify: the files listing has no failure policy (an empty listing makes "every path is prose" vacuously true, and the test stub returns empty by default). A checkout ahead of the accepted-issue step chains the existing bot to a fallible step in a job with no `contents` scope. The dispatch sweep's one `gh pr list` call reads 30 of 56 open pull requests. A listing failure in the sweep exits green. There is no kill switch for Rule 2. The keyword step's exact-string marker cannot match the claim comment's marker on mixed sets (and `sort -u` orders `9` after `132`). The title path is dead under the keyword step's body-only guard. The existing steps fail on dispatch unless gated. Path text reaches a `::error::` workflow command. The concurrency group collapses on dispatch. Two steps fetch the same listing. + - Impact: Medium (each Low to Medium. Together they describe a bash-and-Python hybrid that cannot be made consistent). + - Confidence: Likely (each traced to a line of the workflow or the tool. The ML Engineer reproduced the sort order locally). + - Evidence anchor: `pr-intake.yml` lines 18-20, 25-27, 36, 78, 88-105. `gh pr list --help` default limit 30. `closing_choice.py` docstring "Nothing from them is echoed". + - Failure mode: a green run that did nothing, a required check that flakes on an API hiccup, or a duplicate comment on the pull requests the design most wants to handle gently. + - Blind spot: the design kept the bash steps and added a Python step beside them, so every shared fact (the box, the keyword, the markers, the listing) has two readers. + +12. The design document names a contributor by full name as the problem case, in a directory that publishes to `main`, with a count that is wrong on the day of writing (14 of 15 declare a closing keyword on a milestoned issue. None of the 15 has received a human review). + - Impact: Medium. Reputational, in a public governance record, reversible only by a history rewrite the project denies on `main`. + - Confidence: Likely (fires if committed as written). + - Evidence anchor: design v1.1 "Raghuvar Karthik Durga's fifteen open pull requests". + - Failure mode: the contributor finds the sentence. Any later conduct question starts from it. + - Blind spot: design documents are written as memos and published as record. + +Cross-attack log. +- Governance challenged Data Scientist finding 1 with the roadmap-accounting reading. Red Teamer added that the `Part of` substitution also removes today's merge-time advisory for those pull requests. Confidence held at Very likely. Impact raised to Critical because the two perspectives together show the rule loses on both the attention axis and the accounting axis. +- ML Engineer challenged the Data Scientist's "one action" claim from the other side: even if assignment were routine, the label has no invalidation event, so the clearing step cannot be one action. Merged into findings 2 and 7. +- Red Teamer's auto-merge path (finding 10) was tested against the Governance claim that "people close": auto-merge is on and approvals survive body edits. Confidence set at Plausible rather than Likely because no incident exists. Impact High because the harm is the design's named harm. +- Governance finding 6 (red check reverses the 2026-09-09 decision) was tested against the owner's explicit ask for a closing-choice failure. The earlier design's reasoning stands on the data (one lead false positive on three merged editorial pull requests), so the finding survives and the v2 design records the deviation for the owner to override. +- The ML Engineer's "checkout with no contents scope" was challenged (public repository checkout may succeed without the scope). Held at Plausible. The fix is one permission line either way. +- Dropped as correlated rather than independent: the four restatements of the `help wanted` contradiction and the three of the `Part of` bypass were merged, with no band raised on count. + +Stop signal: not reached. Findings 1, 2, 3, 7, 8, 9 are High or Critical at Likely or above and change the design's shape. The design was rewritten as v2.0 before Round 2. + +## What v2.0 changed in response + +- One gate, not two rules. A non-roster pull request is reviewed when its author holds a referenced accepted issue (assigned, or the issue's author) or when a roster member has reviewed the pull request. The editorial box waives that only for Markdown. The claim rule keys on any reference to an accepted issue, so `Part of` is no longer a bypass (findings 1, 3, 6). +- The bot owns the queue label: it sets it when the gate holds and clears it when the gate no longer holds, re-evaluated on every pull request event, nightly, and on dispatch. The health issue lists queued pull requests (findings 7, 8). +- A roster review counts as a claim accepted, and an accepted issue's author holds it by default (finding 8). +- No red required check. The closing-choice check is unchanged. A code-path editorial pull request is queued, not failed. Recorded as a deviation from the owner's "closing_choice rejects the editorial box" ask, for the owner to override (finding 4). +- Prose is a `.md` suffix or a root notice file, never under `.github/`. Roster authors keep the box on any path (finding 5). +- Auto-merge is disabled on a pull request the bot queues (finding 10). +- One Python tool replaces the bash steps: one parser, one marker, one comment edited in place, paginated listings, a kill switch, an attended sweep that fails red, path text sanitized (finding 11). +- CONTRIBUTING's "What We Need" and the bot comment are rewritten so `help wanted` says claim first (finding 9). +- The design names no contributor and states the measured count (finding 12). + +## Round 2. Methodology and inputs (against v2.0) + +Perspectives spawned: ML Engineer (lead), Data Scientist. Rounds 2 to 5 were run as one batch against v2.0, each perspective assigned its round's layer, because v2.0 was a whole-design rewrite and the layers were independent. + +Premortem narrative. Six months out, the gate's measurable outcome reads zero every night and nobody can say whether it did anything: the count measured the bot's agreement with itself. Thirty accepted issues filed by eleven outsiders, milestoned in one afternoon three weeks before the rule existed, became thirty standing pass tokens, and one contributor with one accepted issue referenced it on five unrelated pull requests. Twelve pull requests carry two bot comments, the old one still promising that the editorial box clears them. The weekly agenda lists eight Dependabot bumps under "queued more than 30 days". + +Findings, surviving at Plausible or above. + +1. The v2 replay over the 56 open pull requests: 16 pass, 40 queue (28 "accepted and not held", 11 "no accepted issue", 1 editorial on code). Four of the six Round 1 collateral cases are resolved. PR #255 and PR #259 (Aaron Ormerod, who has a merged lead-approved pull request on file) still queue, because the gate reads no fact about a contributor's record. + - Impact: Medium. Confidence: Very likely (replayed with the repository's parser, roster, and live facts). + - Resolution in v3: gate 3, the author's merged-and-approved record. + +2. Issue authorship as a hold is a retroactive trust grant: 30 of 78 open accepted issues were filed by non-roster authors, all accepted by one milestone action on 2026-10-05. One filer holds 16. Any one of them clears any pull request that mentions it, and the "unassign" remedy does not exist for a hold nobody granted. PR #337's `Fixes #336` (an unlabeled issue its author filed the same day) needs one milestone to pass. + - Impact: High. Confidence: Very likely (measured. Three perspectives independently). + - Resolution in v3: assignment is the only hold. A filer who wants to build is assigned at acceptance. + +3. The success metric is satisfied by labeling everything. At open time the gate would have queued 4 of the 6 non-roster pull requests this repository has ever merged (PR #254, PR #202, PR #48, PR #168). + - Impact: High. Confidence: Likely. + - Resolution in v3: three outcome counts on the health issue with baselines and a written failure condition. False-queue and false-pass counted. + +4. The claim step's measured reply rate where queued authors already tried it is zero: issue #92 (20 comments from 9 outsiders, 0 roster), issue #33 (45, 7, 0), issue #182 (8, 0). The one claim that worked took 18 days. Three of the eleven GOVERNANCE leads hold no repository access and cannot assign. + - Impact: High. Confidence: Very likely. + - Resolution in v3: the comment no longer promises assignment. A prior comment on the issue is named as a waiting claim. `help wanted` plus a comment passes outright. "claimed, unassigned" is a health-issue count. Issue #145 stays the real fix. + +5. "Any review state" lets a months-old `CHANGES_REQUESTED`, a `DISMISSED`, or a drive-by `COMMENTED` review clear a pull request forever. Five of the seven review-based passes rest on a review at a stale commit. A null review user crashes the event run. + - Impact: High. Confidence: Very likely (review objects read. Three perspectives). + - Resolution in v3: `APPROVED` not dismissed, or `CHANGES_REQUESTED` within 30 days, by a roster login with `OWNER`, `MEMBER`, or `COLLABORATOR` association. Null users and `Bot` reviewers skipped. A roster hand clear counts. A 30-day-silent sent-back state is listed. + +6. The editorial exemption's non-roster positive class is empty in the record (0 of 6 merged non-roster pull requests ticked the box), while a `.md` suffix admits 23 normative spec pages. Ticking the box on PR #255 or PR #294 would pass a normative-text change with no issue. + - Impact: Medium. Confidence: Likely. + - Resolution in v3: the box covers the documentation lane only, one definition shared with the publishing lane. + +7. Twelve open pull requests carry the old marker-less queue comment. The first sweep would post a second comment on each. Per-pull-request listings (files, reviews, comments) are unpaginated (PR #169 has 213 files). The comment reports one route where PR #354 fails two. The Facts record lacks labels, state, draft, age, and reference kind. The `edited` guard misses base changes. Dependabot's changelog anchors parse as this repository's issue numbers. + - Impact: Medium. Confidence: Very likely (each measured). + - Resolution in v3: legacy comment adoption. All listings paginate. Every unmet route rendered. Facts widened. Base in the guard. Anchors to other repositories dropped. Bots out of scope. + +Cross-attack log. +- The Data Scientist's "author record" pass fact was tested against the Red Teamer's abuse lens (Round 3): a merged lead-approved pull request is a lead act, not forgeable, and clears nothing in the fifteen-PR set. Adopted. +- The ML Engineer's proposal to count `COMMENTED` reviews was rejected by the Red Teamer's and Governance's independent evidence that a thread reply is the lead's channel for saying no. The Data Scientist's 30-day expiry on `CHANGES_REQUESTED` was adopted over the Red Teamer's head-SHA rule, because approvals are already dismissed on push by the ruleset and a changes-requested review is engagement that decays, not a commit-bound fact. + +## Round 3. Adversarial robustness and trust boundaries (against v2.0) + +Perspectives spawned: Red Teamer (lead), Security Architect. + +Premortem narrative. Six months out, a contributor who wanted a roadmap issue closed got a lead to leave a one-line comment review, force-pushed a schema change, edited the body to `Fixes`, and auto-merge took it: the bot never queued the pull request, so the control that disables auto-merge never ran. A forged `` comment on another pull request became the text the bot "edited" and the nightly sweep copied into the pinned agenda. A fork pull request with twenty 3,000-character paths made the sweep red every night. A `gh api -f body=@...` read a runner file into a comment. + +Findings, surviving at Plausible or above. + +1. A roster review of any state at any commit is a permanent pass, and it is the branch where the auto-merge control never engages. + - Impact: Critical. Confidence: Likely (five of seven live review passes at stale commits. `allow_auto_merge: true`. The ruleset dismisses on push only). + - Resolution in v3: gate 2 as above. Auto-merge disabled on any non-roster `edited` run that adds a closing keyword on a milestoned issue, whatever the gate decides. The disable failure is red on event runs. A rehearsal precedes enforcement. + +2. The queue reason "read from the bot comment" is a fork-writable channel into the locked health issue, and marker-only comment discovery lets the bot edit a contributor's comment. + - Impact: High. Confidence: Likely. + - Resolution in v3: comment discovery by exact `BOT_LOGIN` plus marker. The health renderer reads data lines only from bot-authored comments. No logins rendered. + +3. `pull-requests: write` can rewrite any pull request body, which is the squash message the roadmap reads delivery from. The design's text-safety rule covered workflow commands and the comment, not argv. + - Impact: High. Confidence: Likely for scope, Plausible for exploitation. + - Resolution in v3: a shared `gh_runner.py` with an argv allowlist, bodies on stdin, `PR_NUMBER` validated, stderr never echoed. A byte-identical argv test under a hostile fixture. + +4. The auto-merge disable's permission need is unverified and the rollout never calls it (0 of 56 open pull requests have auto-merge on). + - Impact: High. Confidence: Plausible. + - Resolution in v3: rehearsal step with assertion. Convert-to-draft fallback. + +5. `synchronize` under `pull_request_target` makes every fork push an unapproved write-token run against the shared hourly budget. A hostile pull request with long paths overflows the comment and makes the sweep red nightly. Two hundred open pull requests cost more than the budget per sweep. + - Impact: High. Confidence: Likely. + - Resolution in v3: `synchronize` re-evaluates only when the box is ticked. Paths capped at 200 characters and twenty, comment at 8,000. Facts hash skips unchanged pull requests. Per-pull-request degrade. Rate-limit remaining in the summary. + +6. The roster is fifteen login strings, three without repository access and three uninvolved org admins. A drive-by review from any is a pass. A re-registered login inherits the trust. The kill switch off is below the status quo. Claim comments are free and invisible. The dispatch sweep's `gh pr list` default reads 30 of 56. The files endpoint caps at 3,000. + - Impact: Medium each. Confidence: Likely. + - Resolution in v3: association check on reviews. Legacy mode equals today's behavior. "claimed, unassigned" count. Pagination everywhere. Truncation detected. + +7. Code execution from the fork through `pull_request_target`: tested negative under the stated constraints (base-branch YAML, trusted checkout, text via `env`, `python3 -I -S`), with three conditions the builder must keep (no path or title in argv or `--jq`, pinned action SHAs, the author filter on comments). Supply chain, checkout head, and the `${{ }}` rule were checked and held. + - Impact: Critical if a condition fails. None while they hold. Confidence: Unlikely as designed. Parked in the tail-risk line. + +Cross-attack log. +- The Security Architect's "recompute the reason from facts at sweep time" was weighed against the ML Engineer's "data line in the comment": recomputation doubles the nightly API cost, and the channel the Architect feared is closed by the exact-login author check. The data line, author-checked, was adopted. The Architect's counterargument conceded the point. +- The Red Teamer's per-issue cap of two was tested against the owner's non-goal (no per-author cap): it is per issue, bounded, and leaves the assigning lead as the drain. Adopted. +- The Security Architect confirmed zizmor is clean on the v2 trigger set and that the checkout can equal the head only when the head is the protected branch itself. + +## Round 4. Operations (against v2.0) + +Perspectives spawned: MLOps / SRE (lead). + +Premortem narrative. Six months out, the rule merged on a Thursday and the promotion landed twelve days later. In between, every new pull request arrived with no label and no comment while the nightly sweep reported green, and then the first live sweep posted forty comments in one minute, fifteen to one author, and went red on request twenty-one with a secondary rate limit. The red email went to one mailbox. A lead unset the variable. The forty comments stayed. + +Findings, surviving at Plausible or above. + +1. The gap between merging to `integration` and promoting to `main` switches intake off entirely: the old bash steps are gone, the tool is absent on `main`, and the measured gap after other people's merges is 7.3, 10.5, and 17.1 days. + - Impact: High. Confidence: Very likely. + - Resolution in v3: the job checks out the pull request's base ref (same ruleset, same approval), so the tool binds on merge. Recorded as a change from the closing-choice precedent for the owner. + +2. The kill switch freezes a bad state with no undo. A wrong first sweep reaches 50 pull requests and 24 authors and the retraction rides the promotion cadence. + - Impact: Critical. Confidence: Likely. + - Resolution in v3: `mode=retract`. Every write reversible. Legacy comment adoption so day one leaves one bot comment per pull request. + +3. Nothing interlocks the variable with the tool version that was dry-run. The first enforcing run is a contributor's event within hours. + - Impact: High. Confidence: Likely. + - Resolution in v3: enforcement requires `PR_INTAKE_ENFORCED=true` and `PR_INTAKE_TOOL_SHA` equal to the tool's blob SHA. A changed tool runs in legacy mode until re-armed. + +4. A red nightly sweep emails whoever last edited the cron line, the repository's measured response to a red scheduled run is none (1 failure in 249 runs, never rerun), and a persistent cause is red every night. + - Impact: High. Confidence: Very likely. + - Resolution in v3: `monitor_roadmap.py` checks the last scheduled intake conclusion and comments on the health issue mentioning every lead on change (decision 17 reused). `::error::` per failed pull request. The sweep's status line at the top of the health section. + +5. Two writers with no version check (event run and sweep), `cancel-in-progress` cutting a run between the label and the comment, an unpaced write burst at the 80-per-minute line, no `Retry-After`, forty needless comment edits a night, no cron time, and a health render that runs before the sweep would. + - Impact: Medium. Confidence: Likely. + - Resolution in v3: re-read `updated_at` before writing. Comment before label. Writes only on change. Paced. `Retry-After` honored. Cron 03:30 before the 04:10 render. Concurrency expression pinned. + +6. Stub-based workflow tests cannot catch a real API shape change (the bot login already differs between GraphQL and REST. `gh --json` fields differ from REST). + - Impact: Medium. Confidence: Likely. + - Resolution in v3: recorded real responses as fixtures. The legacy-mode sweep fails red when facts read empty for a pull request the listing says has data. + +Cross-attack log. +- The SRE's counterargument to its own top finding (Rock promotes same day after his own merges) was tested against the measured windows and the CODEOWNERS rule that the approving lead, not the author, sets the merge time. The finding held and the base-ref checkout was adopted over the "keep the bash steps as a fallback" alternative because the fallback keeps two readers of every shared fact for an unbounded window. + +## Round 5. Governance and second-order effects (against v2.0) + +Perspectives spawned: Governance / Risk (lead). + +Premortem narrative. Six months out, the recruiting funnel has a step with nobody in the middle: `help wanted` says claim it, the claim goes unanswered, the pull request is queued, and the health issue lists the contributor's handle under "queued more than 30 days" in a locked issue they cannot reply to. The conformance report issue #145 named as the contribution the project most wants was the second row. + +Findings, surviving at Plausible or above. + +1. The claim step is unowned and measured at one grant in four. The bot's comment promises an action the project does not perform. + - Impact: Critical. Confidence: Very likely. + - Resolution in v3: as Round 2 finding 4, plus the `help wanted` standing-request route, so the advertised path needs no lead action. + +2. The health issue's queued section would list contributors by login in a record whose own test forbids it. + - Impact: High. Confidence: Very likely. + - Resolution in v3: numbers, reason codes, days. One search link. The invariant's test extended. + +3. The retroactive sweep as written queues 40 of 56, including PR #78 (which the owner told others to coordinate with), PR #113 (named by issue #145 as wanted), and two drafts. The collateral grew from Round 1's six because v2 widened the reference rule. + - Impact: High. Confidence: Very likely. + - Resolution in v3: drafts out of scope. The rollout's step 3 has leads clear the collateral by the gate's own facts from the dry list before arming. Per-author batching on the first sweep. The 30-day clock starts at the sweep for pre-rule pull requests. + +4. Four contributor-facing surfaces would contradict the gate (the template's "open the PR anyway", README's paragraph and diagram, CONTRIBUTING lines 73-74, the reference-implementation form), and CONTRIBUTING's precedence sentence covers only the scope section. + - Impact: Medium. Confidence: Very likely. + - Resolution in v3: all four edited in the same pull request. A precedence sentence added to "How work gets accepted". A guard test on the phrase. + +5. The deviation from the owner's red-check ask has no decision owner or date. `trusted_logins` now serves three purposes and the roster-change procedure does not know. A GOVERNANCE sentence overloads "assign". + - Impact: Medium. Confidence: Likely. + - Resolution in v3: the owner writes one line before the implementing pull request opens. A sentence under "How leadership changes" says what a roster entry grants. The claim sentence names the act. + +Cross-attack log. +- Governance's proposal that `help wanted` plus a comment should pass was tested against the Red Teamer's abuse lens: the exposure is bounded to issues leads chose to advertise, the comment is public, a lead withdraws consent by removing the label, and the fifteen-PR author has zero comments on the eighteen issues his pull requests close. Adopted. +- Governance's objection to issue-authorship holds matched Round 2 and Round 3 from independent data. Dropped from v3. + +## Dropped ledger + +- A contributor who holds one accepted issue references it on unrelated pull requests. Evidence: issue #184, five pull requests by its author. Posterior: Likely, but bounded by the per-issue cap of two and shown on the health issue. Accepted, not dropped. Recorded here because the residual is deliberate. +- The roster trusts login strings and a renamed login could be re-registered. Posterior: Unlikely (GitHub's released-username behavior, no incident). Trigger that would raise it: a roster member renames. Held as a tail risk. The reconciliation follow-up and a user-ID pin in the roster test are the remedy. +- Two queue comments when a milestoned issue lacks `status:accepted` (v2). Posterior: Unlikely in v3, which has one comment per pull request by construction. Dropped. +- The concurrency group collapsing on dispatch (v2). Resolved by the pinned expression. Dropped. +- `closing-choice` runs only for `integration` while intake has no branch filter. Posterior: Unlikely to matter. The docs lane carries no code and v3 moved the editorial rule into intake. Dropped. +- Path text reaching a `::error::` workflow command. Resolved: nothing from the pull request reaches a workflow command in v3. Dropped. + +Tail risks (Critical, irreversible, below Plausible): code execution from a fork through `pull_request_target`, parked with its three conditions (Round 3 finding 7). Trigger that would raise it: any pull request text reaching `gh` argv, `--jq`, or a shell. + +## Convergence statement + +Round 5 was the last layer. Rounds 2 to 5 were run against v2.0 in one batch and every surviving finding is resolved or accepted in v3.0 above. No round against v3.0 has been run. The implementation plan's premortem attacks v3.0 as built. + +## Prioritized remediation plan + +Ordered by expected cost reduction per unit of effort. Each item names the v3.0 section that carries it and the test that proves it landed. + +1. Count only `APPROVED` (not dismissed) or `CHANGES_REQUESTED` within 30 days by a roster login with a repository association. Skip `COMMENTED`, `DISMISSED`, null users, bots. Closes Round 3 finding 1 (Critical, Likely). Owner: the implementer of `decide`. Verify: `test_pr_intake.py` review-state cases. +2. Disable auto-merge on queue and on a non-roster `edited` run that adds a milestoned close. Red on failure. Rehearse before enforcement. Closes Round 3 findings 1 and 4. Verify: the rehearsal record in the rollout, `test_pr_intake.py` auto-merge cases. +3. Assignment is the only hold. `help wanted` plus a prior comment passes. The author's merged-and-approved record passes. Per-issue cap of two. Closes Round 2 findings 1, 2, 4 and Round 5 findings 1, 3. Verify: the replay over the 56 open pull requests in the plan's premortem, and the named `decide` cases. +4. Base-ref checkout. Two-variable arming with the tool SHA. `mode=retract`. Legacy mode equals today. Closes Round 4 findings 1, 2, 3 and Round 3 finding 6. Verify: `test_pr_intake_workflow.py` checkout and mode cases. A retract run on one pull request before enforcement. +5. Exact-login comment discovery with legacy adoption. Data line. Health section with numbers, reasons, days, counts, and the sweep status. The monitor's conclusion check. Closes Round 3 finding 2, Round 4 finding 4, Round 5 finding 2, Round 2 findings 3 and 7. Verify: `test_roadmap_sync_health.py` and `test_monitor_roadmap.py` cases. +6. `gh_runner.py` with the argv allowlist and stdin bodies. Closes Round 3 finding 3. Verify: `test_gh_runner.py` byte-identical argv. +7. Pagination everywhere, truncation detection, caps, pacing, `Retry-After`, write-on-change, staleness guard, comment-before-label, cron at 03:30, concurrency expression, `synchronize` scoped to the editorial tick. Closes Round 3 finding 5 and Round 4 finding 5. Verify: the workflow tests named in v3.0. +8. The documentation lane as the editorial definition. The four contributor-facing surfaces edited. The precedence sentence. The template tick test. Closes Round 2 finding 6 and Round 5 finding 4. Verify: the guard tests. +9. Recorded real fixtures and the legacy-mode empty-facts check. Closes Round 4 finding 6. Verify: `tests/fixtures/` and the sweep case. +10. The owner's one-line decision on the red check. The GOVERNANCE sentences. The roster reconciliation follow-up. Closes Round 5 finding 5. Verify: the line in v3.0 before the implementing pull request opens. + +## Residual risk + +- The roster as the author exemption still trusts a Markdown table. A reconciliation with repository access is a follow-up with an owner (the project leads) and precedes enforcement. +- Claims on issues have no responder. The design stops promising one and makes the backlog visible, and issue #145 is the fix the project already filed. +- A contributor who holds one assigned issue can clear two pull requests with it. Deliberate. +- Integration's ruleset is trusted as much as main's for the checkout. Both require one code-owner approval. Both are admin-bypassable. The owner may revert to the `main` checkout at the cost of the measured gap. +- The tail risk above, with its conditions pinned by tests. + +## Plan premortem (against `design/plans/2026-10-29-pr-intake-rules.md` v1.0) + +Perspectives spawned, one batch, each attacking the plan as an executable artifact handed task by task to fresh implementers: Red Teamer, Data Scientist, ML Engineer, Security Architect, MLOps / SRE, Governance / Risk. Each read the plan, the spec, this report, the code the plan modifies, and the live repository, and several replayed the plan's rules against the 56 open pull requests. Sixty-eight findings came back. After merging duplicates, forty-one survived at Plausible or above. All are resolved in plan v2.0. + +Premortem narrative. Six months out, the gate's monitor never ran because the workflow never passed it the switch. The health section's first line was unreadable every night for want of a permission. A tool fix de-armed the gate for three weeks and every signal stayed green. The motivating pull request passed on the strength of a dogfooding comment. One contributor's fifteen pull requests passed after one comment on issue #92. The first "dry" sweep wrote thirty labels. A lead's one-click clear outlived three pushes because the timeline's commit dates belong to the pusher. + +Findings, merged, with the plan's resolution. + +1. The monitor's intake check reads a variable the workflow never passes (ML Engineer, Governance, SRE). High, Very likely. v2.0 Task 9 passes both variables and extends the job `if`. The default `read_runs=lambda: None` keeps the existing `evaluate` calls. +2. The roadmap sweep reads the Actions runs endpoint without `actions: read`, and the plan forbade the workflow edit. An unwrapped failure degrades every section and latches the roadmap alarm (ML Engineer, SRE, Security Architect, Data Scientist). High, Likely. v2.0 Task 8 grants the permission on both jobs, reads runs with `gh.get`, and wraps the intake reads in their own degradation. +3. The runner allowlist cannot serve the roadmap fetch's GraphQL argv or the monitor's `-f body=`, so Task 1's delegation step forces a widening to free text (ML Engineer, SRE, Security Architect). High, Very likely. v2.0 Task 1 sends GraphQL bodies on stdin through `--input -`, forbids `-f`, `-F`, `--jq`, `-q`, `-t`, `--template` in every position by test, and leaves the monitor's post on its own subprocess. +4. The workflow harness runs the `run:` line against a fake `gh` on `PATH`, but the runner calls `/usr/bin/gh` by absolute path (ML Engineer). High, Very likely. v2.0 Task 6 adds `--gh PATH` for tests and Task 7 pins the real run line separately. +5. `Facts` lacked the event action, the per-issue use, and a source for the rule's landing date. The edit-adds-a-close rule had no input to compute "adds" (ML Engineer, Security Architect, Red Teamer). High, Likely. v2.0 keys the auto-merge disable on state (auto-merge on, non-roster author, a close on an open milestoned issue) on every event including `auto_merge_enabled`, which the Red Teamer showed is the event the edit rule missed. `RULE_LANDED` is a module constant. `held_use` is a field with an ordering rule. +6. A tool change silently de-arms the gate and nothing renders the mode. The arming SHA covered one of five modules (Governance, SRE, Security Architect). High, Likely. v2.0 Task 6 makes a mismatched sweep red with the remedy in the error line, prints the mode first in every summary, and arms over five modules. +7. A legacy-mode sweep writes, so the rollout's dry sweep is not dry, and legacy mode dropped today's editorial and already-commented exits (Governance, SRE, Data Scientist). High, Likely. v2.0 defines legacy as today's three exits on event runs only, adds `dryrun`, and makes any legacy sweep write-free. +8. The nightly sweep spends 440 to 816 of the 1,000 hourly calls in the roadmap sweep's hour with no cap, no primary-limit abort, and no job timeout. Per-reference reads let one body spend the budget. Task 7 deleted the one-call regression test (SRE, Red Teamer, Security Architect). High, Likely. v2.0 filters bots and drafts before collection, resolves references against the listings only, caps claim reads at five, skips unchanged pull requests on a `seen` key, reads the rate limit every ten evaluations, stops at a reserve, and sets a twenty-minute timeout. The old test is carried over. +9. The motivating example, PR #354, passes through the help-wanted route on a dogfooding comment, and that route is uncapped (Data Scientist, Red Teamer). Critical, Very likely. v2.0 queues an editorial tick with any off-lane path before the issue routes are read, and caps the help-wanted route at two open pull requests per author per issue, the same as assignment. +10. "False queues" measured the designed path (approved non-roster merges land within 1.4 hours of approval, with no trigger on review), and the baselines could not be read from the sweep summary (Data Scientist, Governance, SRE). Critical, Very likely. v2.0 adds the `pull_request_review` trigger, counts hand clears by route from the data line, records baselines in a repository variable with a review date, and compares 60-day windows. +11. `last_push_at` from `committed` timeline events is a git date the pusher sets, and the events carry no `created_at` (Red Teamer, Security Architect, Data Scientist). High, Likely. v2.0 adds the `unlabeled` trigger, records `cleared_head` on the data line, holds a hand clear while the head matches, and never reads commit dates. +12. A changed path rendered into the bot's own comment reopens the fork-writable channel into the locked health issue and lets the bot mention anyone (Red Teamer, Security Architect). High, Likely. v2.0 sanitizes paths, puts the data line on line 1 and parses only line 1, and drops malformed rows in the renderer. +13. The facts hash short circuit swallowed the 30-day expiry and every omitted fact. `since` reset on every rewrite (Data Scientist, ML Engineer). High, Likely. v2.0 hashes every field `decide` reads plus the date-dependent booleans, and carries `since` forward while the outcome stays queued. +14. "Sent back, no response" labeled the lead's silence as the contributor's on the one live case, and "claimed" counted critiques and questions (Data Scientist). Medium, Very likely. v2.0 adds `awaiting_rereview`, counts claims once per author and issue, and softens the claim line. +15. The rollout had no actor, surface, or exit criterion, armed before promoting (so the first enforced sweep ran with no agenda and no alarm), and `Closes #362` closed the tracking issue at step 1 (Governance, SRE). High, Likely. v2.0 Task 11 is a checklist posted on issue #362 with actors and expected results, promotion before arming, `Part of #362`, and a hand close. +16. The legacy-comment adoption dropped the bot-login condition, so the bot could overwrite a contributor's comment (Security Architect). Medium, Plausible. v2.0 requires the bot login on both branches. +17. Every `type: Bot` author out of scope is below today for GitHub App authors (Red Teamer). Medium, Plausible. v2.0 scopes out `KNOWN_BOTS` only. +18. Seven Dependabot pull requests carry the label with no bot record, and the summary could not reconcile the out-of-scope count (Data Scientist). Medium, Very likely. v2.0 adds `out_of_scope` to the summary identity, a bot-scoped retract before the live sweep, and a "Labeled without a gate record" sub-list. +19. The CONTRIBUTING line range deleted the closing-keyword paragraph a test pins, the seven route sentences were not written, the template path and the link-check test were misnamed, the `help wanted` label still promised "safe to start without waiting on triage", and the README's governance link was not protected (Governance, ML Engineer). High, Very likely. v2.0 Task 10 carries the sentences verbatim, the exact line ranges, the tracked file names, the label seed, and a test that the prose follows `GATE_ORDER`. +20. The owner's deviation line was never written, and the roster reconciliation the spec made a precondition was dropped (Governance). Medium, Likely. v2.0 Task 11 Step 0 is the owner's line and the rollout's precondition is the reconciliation. +21. Smaller: no reason code for a truncated listing. `rate_limit` outside the allowlist. `Retry-After` unreadable without `-i`. `paginate` failure unspecified. Logins not casefolded. The health snapshot's three collisions with `roadmap_sync` as it stands. The monitor alarming on an in-progress run. The auto-merge draft fallback with no code path. Fixtures carrying commit emails. The null review user pinned at the wrong layer. The base-ref guarantee false for 26 branches. The cron-order test proving nothing. Zizmor pinned at a version CI does not run. All adopted in v2.0 as the perspectives proposed. + +Cross-attack log. +- Hand-clear expiry: the Red Teamer proposed timeline order, the Security Architect a recorded head SHA with an `unlabeled` trigger, the Data Scientist commit dates plus a head record. The head record won: it is a server fact the bot writes, needs no assumption about timeline ordering, and the trigger makes the first observation immediate. +- Auto-merge: the ML Engineer and the Security Architect built the "adds" rule from before-and-after bodies. The Red Teamer showed the dangerous moment is the enable, not the edit, and that `pull_request_target` has an `auto_merge_enabled` type. The state rule won, and the trade (no auto-merge on non-roster pull requests that close roadmap issues) is recorded in the spec. +- Reference reads: the Data Scientist wanted one `issues/{n}` read for numbers in neither listing. The Red Teamer and the Security Architect showed any per-reference read is a budget attack. The listings won: the accepted listing is read with `state=all`, pull request numbers come from the two pulls listings, and a number in no listing is "not accepted" without a claim about what it is. +- Help-wanted cap: the Data Scientist argued for one, the Red Teamer for two. Two won, one rule for both issue routes, stated in CONTRIBUTING. +- Budget stop: the Red Teamer proposed exit 0 with the rest skipped, the SRE exit 1. Exit 1 won: a sweep that could not finish is a condition a lead must see, and the mode line names it. +- Timeout: ten minutes (Red Teamer) against twenty (SRE). Twenty, because pacing at one second per write over forty queued pull requests plus the reads is already past ten. +- The data line's position: last match (Red Teamer) against line 1 (Security Architect). Line 1 won. It needs no multiline parse and a path cannot precede it. + +Dropped ledger. +- Red Teamer, the base-ref checkout as a new capability: Remote (a write-access branch already runs its own workflow file). The honest comment and the `if:` guard were adopted for the statement's sake. +- Security Architect, `actions: read` possibly unneeded on a public repository: not tested. The permission is added regardless and the dry run records the observation. +- SRE, red on every tool edit trains the leads to expect red: weighed against the measured response to a green run (none) and held the other way. + +Convergence statement. The six perspectives reached the same five High findings from different evidence (the monitor switch, the sweep permission, the runner allowlist, the dry sweep that writes, the de-arming tool change), and the Critical findings (the motivating example passing, the outcome measure counting the designed path) came from the Data Scientist's replay alone, which the other lenses could not have produced. No finding contradicted a spec-level resolution. Eleven amend the spec and are listed in Task 11. Stop signal reached for the plan: a second round would re-read the same code. diff --git a/design/plans/2026-10-29-pr-intake-rules.md b/design/plans/2026-10-29-pr-intake-rules.md new file mode 100644 index 00000000..7bc90f72 --- /dev/null +++ b/design/plans/2026-10-29-pr-intake-rules.md @@ -0,0 +1,290 @@ +# PR Intake Gate Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +Version 2.0, 2026-10-29, rewritten after the plan premortem (six perspectives. Findings and resolutions in `design/2026-10-29-pr-intake-rules-premortem.md`, "Plan premortem"). + +Deviations from this plan are recorded in the design's Amendments section. + +**Goal:** Replace the intake bot's two bash steps with one Python gate that queues pull requests no maintainer asked for, owns the queue label, lists the queue on the health issue, and returns to today's behavior when switched off. + +**Architecture:** A pure `decide(facts, today)` in `tools/pr_intake.py` over a `Facts` record collected through a shared, allowlisted `gh` runner (`tools/gh_runner.py`) that never puts free text on argv. The workflow checks out the pull request's base ref and runs the tool on events, nightly, and on dispatch. `roadmap_sync.py` renders a queued section from the bot's own data lines. `monitor_roadmap.py` watches the nightly sweep. Every write the bot makes is reversible by a dispatch. + +**Tech Stack:** Python 3 standard library only (`python3 -I -S`), GitHub Actions, `gh` CLI, pytest with the repository's stubbed-`gh` harness. + +**Spec:** `design/2026-10-29-pr-intake-rules-design.md` v3.0, with the amendments the plan premortem made (listed in the premortem's "Plan premortem" section and applied to the spec in Task 11). Tracking issue #362. + +## Global Constraints + +- `tools/pr_intake.py` and `tools/gh_runner.py` import only the standard library and sibling modules under `tools/`. They run under `python3 -I -S`. Every pattern in them is linear with bounded repeats and no nested quantifiers, the rule `closing_choice.py` states for itself. +- `pr-intake.yml` keeps `permissions: {}` at the top. The job has `contents: read`, `pull-requests: write`, `issues: read`. No `${{ }}` inside any `run:` in any job. The pull request head is never checked out. The job carries `timeout-minutes: 20`. +- Nothing from a pull request (title, body, paths, logins) reaches `gh` argv, a `--jq` program, a shell, or a workflow command. Bodies go to `gh` on stdin as JSON. The allowlist never admits `-f`, `-F`, `--jq`, `-q`, `-t`, or `--template` in any position. +- Comment discovery is `user.login == roadmap_model.BOT_LOGIN` exactly, plus the marker ``, or for a legacy comment the same login plus the legacy prefix. A comment by any other login is never a candidate. +- Every login the tool compares is casefolded on collection. `roadmap_model.trusted_logins` is already casefolded. +- The health section renders numbers, reason codes, dates, and days only. No contributor login, no fetched text. A data line with a reason outside `REASONS` or a malformed value is dropped and counted. +- `KNOWN_BOTS` (Dependabot and the Actions bot) and drafts are out of the gate's scope in both modes. Any other `type: Bot` author is evaluated as a non-roster author. +- Legacy mode (variables unset or tool SHA mismatch) writes on event runs only, for human authors, and only when no referenced issue is accepted, the editorial box is not ticked, and no bot comment exists. A legacy or dry-run sweep writes nothing. +- Cron `10 3 * * *`. The header comment says ordering before the 04:10 roadmap sweep is best effort under the scheduler delays this repository has measured. Concurrency expression exactly as in the spec. +- Prose follows STYLE.md: no em dashes, no semicolons, American English. +- Commits are signed off (`git commit -s`). Branch `fix/362-pr-intake-gate` from `origin/integration`, deleted after merge. +- File names as tracked: `.github/pull_request_template.md`, `tests/test_doc_links.py`. zizmor runs as CI does: `uv run --locked --only-group zizmor zizmor `. + +## Review Focus + +1. A body whose only accepted reference is inside an HTML anchor to another repository (Dependabot changelog). Expected: not a reference. Pinned in Task 4. +2. A review whose `user` is null (deleted account) or whose `user.type` is `Bot`. Expected: skipped at collection, no crash. Pinned in Task 4. +3. A files listing of exactly 3,000 entries, or shorter than `changed_files`. Expected: queued with reason `paths_truncated`, never an editorial pass. Pinned in Task 3. +4. A legacy bot comment plus a contributor comment that copies the marker or the legacy prefix. Expected: the bot edits only its own comment. Pinned in Tasks 4 and 5. +5. The motivating example, PR #354: editorial box ticked, one path under `reference-implementations/`, `Refs #92` where the author commented on #92 two days earlier. Expected: queued with reason `editorial_off_lane`. Pinned in Task 3. + +--- + +### Task 1: The shared gh runner + +**Files:** +- Create: `tools/gh_runner.py` +- Modify: `tools/fetch_roadmap.py` (`_graphql` sends its body on stdin. `_runner` delegates with the import kept inside the function body so the deploy step's import guard at line 439 holds), `tools/roadmap_sync.py` (`GitHub.call_list(self, args, stdin=None)` passes `input=stdin`), `tests/test_fetch_roadmap.py` (`fake_run(args, stdin=None)` reads the query from `json.loads(stdin)["query"]`. The env assertion at line 259 becomes the six-key set) +- Test: `tests/test_gh_runner.py` + +**Interfaces:** +- Produces: `GH = "/usr/bin/gh"`. `REPO` imported from `roadmap_model`. `class RunnerError(RuntimeError)`. `Runner = Callable[[list[str], str | None], tuple[int, str, str]]`. `make_runner(gh: str = GH, token: str | None = None) -> Runner` where `run(args, stdin=None)` passes `input=stdin`. `paginate(run: Runner, path: str) -> list` raising `RunnerError` on a non-zero exit, non-JSON output, or a page that is not a list. `split_http(stdout: str) -> tuple[int, dict[str, str], str]` lifted from `fetch_roadmap._split_http`. `remaining(run: Runner) -> int | None` reading `resources.core.remaining` from `("api", "rate_limit")` in Python. +- The allowlist is a tuple of argv shape tuples, each element a literal or a compiled pattern. Reads: `("api", "-i", PATH)`, `("api", "--paginate", "--slurp", PATH)`, `("api", "-i", RUNS_PATH)`, `("api", "rate_limit")`, `("api", "-i", "graphql", "--input", "-")`. Writes, every one with `-i` so the status line and headers return on stdout: `("api", "-i", "-X", "PATCH", COMMENT_PATH, "--input", "-")`, `("api", "-i", "-X", "POST", COMMENTS_PATH, "--input", "-")`, `("api", "-i", "-X", "POST", LABELS_PATH, "--input", "-")`, `("api", "-i", "-X", "DELETE", LABEL_PATH)`, `("pr", "merge", DIGITS, "--disable-auto", "--repo", REPO)`, `("pr", "ready", DIGITS, "--undo", "--repo", REPO)`. `PATH` matches `^repos//(pulls|issues)(/[0-9]{1,9})?(/(files|reviews|comments|timeline|labels))?(\?[A-Za-z0-9_=&%.-]*)?$` with `` escaped. `RUNS_PATH` is `^repos//actions/workflows/pr-intake\.yml/runs\?[A-Za-z0-9_=&]*$`. `COMMENT_PATH` is `^repos//issues/comments/[0-9]{1,9}$`. `COMMENTS_PATH` is `^repos//issues/[0-9]{1,9}/comments$`. `LABELS_PATH` is `^repos//issues/[0-9]{1,9}/labels$`. `LABEL_PATH` is `^repos//issues/[0-9]{1,9}/labels/status%3Aneeds-triage$`. `DIGITS` is `^[0-9]{1,9}$`. +- The runner keeps `fetch_roadmap._runner`'s properties: absolute `gh`, env of exactly `{PATH, HOME, GH_TOKEN, GH_HOST, GH_PROMPT_DISABLED, GH_NO_UPDATE_NOTIFIER}`, `timeout=60`, the synthetic 401 without a token, failure tuples for timeout and a missing binary, and `_check_allowlist(args)` raising `RunnerError` before any subprocess. + +- [ ] **Step 1: Write the failing tests**: `test_runner_refuses_argv_outside_the_allowlist`. `test_runner_refuses_free_text_flags_in_every_position` (parametrized over `-f`, `-F`, `--jq`, `-q`, `-t`, `--template` inserted at each index of an otherwise valid shape). `test_runner_passes_a_hostile_body_only_through_stdin` (fake `gh` records argv and stdin. The body carries `@x`, `-rf ../`, and `--jq`). `test_runner_env_is_minimal_and_never_echoes_stderr` (a 40-character token. The fake writes it to stderr. The caller's output carries only a class). `test_paginate_flattens_slurp_pages`. `test_paginate_raises_on_nonzero_exit_and_on_non_json`. `test_runner_without_token_returns_synthetic_401`. `test_graphql_shape_takes_its_body_on_stdin`. `test_remaining_reads_core_remaining_in_python`. +- [ ] **Step 2: Run `uv run pytest tests/test_gh_runner.py -v`. Expected: FAIL, module not found.** +- [ ] **Step 3: Implement `tools/gh_runner.py`.** +- [ ] **Step 4: Run the tests. Expected: PASS.** +- [ ] **Step 5: Move the roadmap fetch onto the runner**: `fetch_roadmap._graphql` sends `json.dumps({"query": query, "variables": {k: v for k, v in variables.items() if v is not None}})` on stdin with argv `["api", "-i", "graphql", "--input", "-"]`. `_runner` delegates to `make_runner` with `import gh_runner` inside its body. `roadmap_sync.GitHub.call_list` gains `stdin`. `tests/test_fetch_roadmap.py` line 259 becomes the six-key set and `fake_run` reads the query from stdin. Run `uv run pytest tests/test_fetch_roadmap.py tests/test_roadmap_sync_health.py -q`. Expected: PASS after those two test edits. +- [ ] **Step 6: Commit** `git add tools/gh_runner.py tests/test_gh_runner.py tools/fetch_roadmap.py tools/roadmap_sync.py tests/test_fetch_roadmap.py && git commit -s -m "Add an allowlisted gh runner that never puts free text on argv"` + +### Task 2: Facts, Decision, reasons, and the data line + +**Files:** +- Create: `tools/pr_intake.py` (data model and parsing only in this task) +- Test: `tests/test_pr_intake.py` + +**Interfaces:** +- Produces, in `tools/pr_intake.py`: + - `MARKER = ""`, `DATA_PREFIX = "$`, keys `reason`, `route`, `issues`, `since`, `cleared`, `head`, `cleared_head`, `facts`, `claimed`, `seen`. `render_data_line(fields) -> str`. `facts_hash(facts: Facts, today: date) -> str` (12 hex of sha256 over every field `decide` reads plus the derived booleans `engaged_by_fresh_review` and `editorial_ticked`). `editorial_ticked(body: str) -> bool` defined as `bool(closing_choice.EDITORIAL.search(closing_choice.normalize(body)))`. `safe_path(path: str) -> str` as `re.sub(r"[^A-Za-z0-9._/-]", "?", path)[:200]`. + - `claimed` is empty or a comma list of `:`. `seen` is `,`. + +- [ ] **Step 1: Write the failing tests**: `test_data_line_round_trips`. `test_parse_data_line_reads_line_one_only` (a well-formed line on line 3 with `since=2020-01-01` is ignored). `test_data_line_rejects_bad_shapes` (a space or `>` in a value, an unknown key). `test_facts_hash_changes_with_references_and_with_a_review_crossing_thirty_days_not_with_updated_at`. `test_editorial_ticked_agrees_with_closing_choice_on_the_template_line`. `test_safe_path_replaces_backtick_marker_and_at`. +- [ ] **Step 2: Run them. Expected: FAIL.** +- [ ] **Step 3: Implement the dataclasses and the functions.** +- [ ] **Step 4: Run them. Expected: PASS.** +- [ ] **Step 5: Commit** `git commit -s -m "Add the intake tool's facts, decision, and data line"` + +### Task 3: The gate: `decide` + +**Files:** +- Modify: `tools/pr_intake.py` +- Test: `tests/test_pr_intake.py` + +**Interfaces:** +- Produces: `decide(facts: Facts, today: date) -> Decision`. `unmet_routes(facts, today) -> tuple[str, ...]`. `render_comment(facts, decision, today) -> str`. `GATE_ORDER = ("bot", "roster", "engaged", "record", "editorial", "held", "help_wanted")`. +- Consumes: `closing_choice.parse_references`, `closing_choice.EDITORIAL`, `closing_choice.normalize`, `base_branch_guard.in_docs_lane`, `roadmap_model.NEEDS_TRIAGE`. +- Routes, in order. `bot`: `facts.author in KNOWN_BOTS` or `facts.draft` skips (no label, no comment). `roster`: `facts.author in facts.roster` passes. `engaged`: an `APPROVED` review by a roster login with association in `{"OWNER", "MEMBER", "COLLABORATOR"}` (dismissed reviews are absent from `facts.reviews`), or a `CHANGES_REQUESTED` by such a login with `submitted_at >= today - 30 days`, or a hand clear that stands: `last_unlabel` by a roster login and `bot_comment.data["cleared_head"] == facts.head_sha`. On the evaluation that first sees a roster unlabel with no `cleared_head` recorded, the decision records `cleared_head=facts.head_sha` and passes. `record`: `facts.author_record`. `editorial`: `editorial_ticked(body)` and every path on the lane and `not paths_truncated` passes. `editorial_ticked(body)` with any path off the lane queues with `editorial_off_lane` naming the paths, before routes `held` and `help_wanted` are read (the motivating example). `editorial_ticked(body)` with `paths_truncated` queues with `paths_truncated`. `held`: a referenced open accepted issue with the author in `assignees`, and this pull request's number among the two lowest in `held_use[n]`. A third queues with `held_cap` naming the two. `help_wanted`: a referenced open accepted issue carrying `help wanted` with `author_commented_at < facts.created_at`, under the same two-lowest rule from `held_use`. +- A `CHANGES_REQUESTED` older than 30 days with no newer roster review yields `awaiting_rereview` when the author pushed (`head_sha` differs from the review's `commit_id`) or commented after it (`last_author_comment_at` later than the review), else `sent_back`. +- `disable_auto_merge` is true whenever `facts.auto_merge`, the author is not on the roster, and `parse_references(title, body)` yields a `close` on an open milestoned issue, on every event. The comment then carries "Auto-merge is off because this pull request closes roadmap issue #n. A lead merges it by hand after confirming delivery." +- The short circuit: when the data line's `facts` equals `facts_hash(facts, today)` and its `reason` equals the new decision's first reason, `label` is `keep` and `comment` is `None`. A changed `(outcome, reasons)` always renders. `since` carries forward from the data line while the outcome stays `queue` and resets on a clear. A clear writes `reason=cleared route= cleared=`. +- Legacy mode: `label` is `set` and a comment renders only when all hold: `not facts.sweep`, no referenced issue carries `status:accepted` in any state, `editorial_ticked(body)` is false, `facts.bot_comment is None`, the author is not in `KNOWN_BOTS`. Otherwise `label` is `keep`, `comment` is `None`, and `notices` carry the would-do lines. Legacy never clears a label. +- Comment text: the spec's wording per unmet route, one paragraph per route. The claim line reads "You commented on #a on . If that was a claim, it is waiting on a lead.", the queued line, the keyword warning, the new-rule line for pull requests opened before `RULE_LANDED`. Paths through `safe_path`, wrapped in backticks, at most 20. "and N more" when `references_dropped`. The data line is line 1 and the marker line 2. The whole comment cut at 8,000 characters. + +- [ ] **Step 1: Write the failing tests**, each through a `facts(**overrides)` helper (defaults: non-roster author `outsider`, no references, enforced mode, roster `{"rocklambros", "lrd0036"}`): + +```python +def test_known_bot_author_is_out_of_scope(): ... +def test_unknown_bot_author_is_evaluated_as_non_roster(): ... +def test_draft_is_skipped(): ... +def test_roster_author_passes_case_insensitively(): ... # author "Lrd0036" stored casefolded +def test_approved_review_by_roster_collaborator_passes(): ... +def test_approved_review_by_roster_login_without_role_does_not_count(): ... +def test_commented_reviews_do_not_count(): ... +def test_changes_requested_at_29_days_passes_and_at_31_days_is_sent_back_or_awaiting(): ... +def test_push_after_changes_requested_is_awaiting_rereview_not_sent_back(): ... +def test_hand_clear_records_the_head_and_holds_while_it_matches(): ... +def test_hand_clear_stops_holding_when_the_head_sha_changes(): ... +def test_hand_clear_by_non_roster_is_reversed(): ... +def test_author_merged_record_passes(): ... +def test_editorial_on_the_docs_lane_passes(): ... +def test_editorial_off_lane_queues_naming_sanitized_paths(): ... +def test_motivating_example_queues(): ... # Review Focus 5 +def test_truncated_paths_queue_with_their_own_reason_and_never_pass_editorial(): ... # Review Focus 3 +def test_first_two_pull_requests_on_a_held_issue_pass_and_the_third_queues(): ... +def test_first_two_pull_requests_on_a_help_wanted_issue_pass_and_the_third_queues(): ... +def test_issue_author_without_assignment_queues(): ... +def test_help_wanted_with_prior_comment_passes_and_without_queues(): ... +def test_help_wanted_comment_after_open_does_not_count(): ... +def test_closed_accepted_reference_gets_its_own_reason(): ... +def test_reference_to_a_pull_request_number_is_not_an_issue(): ... +def test_body_failing_two_routes_renders_both_paragraphs(): ... +def test_prior_comment_is_named_as_possibly_waiting(): ... +def test_legacy_mode_matches_todays_three_exits(): ... +def test_legacy_sweep_writes_nothing(): ... +def test_pre_rule_pull_request_comment_says_the_rule_is_new(): ... +def test_keyword_warning_present_when_a_closing_keyword_names_a_milestoned_issue(): ... +def test_auto_merge_disabled_whenever_on_for_a_non_roster_milestoned_close(): ... +def test_unchanged_facts_keep_label_and_comment(): ... +def test_hash_short_circuit_cannot_hide_a_changed_decision(): ... # parametrized over every Facts field and today at 29 and 31 days +def test_since_survives_a_rerender_and_resets_on_clear(): ... +def test_render_comment_contains_one_html_comment_pair_and_no_at_sign(): ... # hostile paths carrying a backtick, \n" + f"This pull request's description says it closes{''.join(' #' + name for name in names)}, which is on" + " the project roadmap. When it merges, GitHub closes that issue and the roadmap counts it as delivered." + " Please confirm the change delivers the whole issue before merging." + ) + + +def test_legacy_event_run_posts_todays_keyword_warning_once(): + roadmap = issue(60, kind="close", labels=(), milestoned=True) + first = decide(facts(mode="legacy", references=(roadmap,)), TODAY) + assert first.comment == pr_intake.LEGACY_COMMENT and first.label == "set" + assert first.warning == todays_keyword_comment(60) + # Two roadmap issues: one marker naming both, in today's order. + both = decide(facts(mode="legacy", references=(roadmap, issue(9, kind="close", labels=(), milestoned=True))), TODAY) + assert both.warning == todays_keyword_comment(9, 60) + assert both.warning.startswith("") + # An accepted roadmap issue skips the queue but still warns, as today's second step does. + accepted = decide(facts(mode="legacy", references=(issue(60, kind="close", milestoned=True),)), TODAY) + assert accepted.comment is None and accepted.label == "keep" and accepted.warning == todays_keyword_comment(60) + # Posted once: a bot comment already carrying the exact marker stops it. + marked = facts(mode="legacy", references=(roadmap,), keyword_markers=frozenset({""})) + assert decide(marked, TODAY).warning is None + # A marker naming a different set is a different warning, as it is today. + other = facts(mode="legacy", references=(roadmap,), keyword_markers=frozenset({""})) + assert decide(other, TODAY).warning == todays_keyword_comment(60) + assert decide(facts(mode="legacy", sweep=True, references=(roadmap,)), TODAY).warning is None + assert decide(facts(mode="legacy", references=(issue(60, labels=(), milestoned=True),)), TODAY).warning is None + assert decide(facts(references=(roadmap,)), TODAY).warning is None + + +def test_legacy_queues_a_pull_request_whose_only_bot_comment_is_the_keyword_warning(): + keyword_only = BotComment(id=9, body=todays_keyword_comment(60), data=None, legacy=True) + decision = decide(facts(mode="legacy", bot_comment=keyword_only), TODAY) + assert decision.comment == pr_intake.LEGACY_COMMENT and decision.label == "set" + marked = BotComment(id=9, body=COMMENT, data=parse_data_line(COMMENT), legacy=False) + assert decide(facts(mode="legacy", bot_comment=marked), TODAY).comment is None + + def test_pre_rule_pull_request_comment_says_the_rule_is_new(): old = decide(facts(created_at=datetime(2026, 10, 1, tzinfo=timezone.utc)), TODAY) assert "The rule is new, and its 30-day clock starts on 2026-11-10." in old.comment @@ -606,6 +649,7 @@ def _mutations(base: Facts) -> dict: "mode": "legacy", "sweep": True, "event_action": "edited", + "keyword_markers": frozenset({""}), } @@ -1011,6 +1055,17 @@ def test_legacy_bot_comment_is_adopted(): assert pr_intake.LEGACY_KEYWORD_MARKER in keyword_only.bot_comment.body +def test_collect_reads_the_keyword_markers_only_the_bot_posted(): + got, _ = collect(pull_mapping()) + assert got.keyword_markers == frozenset({""}) + comments = fixture("comments.json") + for comment in comments: + if comment["user"] and comment["user"]["login"] == "login-16": + comment["body"] = " copied" + forged, _ = collect(pull_mapping(comments=comments)) + assert forged.keyword_markers == frozenset({""}) + + def test_listing_failure_raises_ListingError(): for path in listing_mapping(): mapping = listing_mapping() @@ -1930,3 +1985,20 @@ def test_sweep_over_the_population_reports_the_expected_summary(monkeypatch): assert summary.evaluated == len(rows) == 56 and summary.writes == 0 and not summary.errors and balanced(summary) assert fake.writes() == [] assert len([line for line in lines if re.fullmatch(r"#[0-9]+ (queue|pass|out_of_scope)( [a-z_,]+)?", line)]) == 56 + + +def test_legacy_event_run_on_a_milestoned_close_writes_what_todays_workflow_writes(monkeypatch, capsys): + roadmap = {**gh_issue(60), "labels": [], "milestone": {"number": 1}} + fake = FakeGitHub([gh_pull(961, body="Fixes #60")], accepted=[roadmap]) + code, out, _ = run_main(monkeypatch, capsys, fake, EVENT_NAME="pull_request_target", EVENT_ACTION="opened", + PR_NUMBER="961") + assert code == 0, out + # Today's two steps: the queue label and comment, then the merger's warning. + assert fake.bodies(961) == [pr_intake.LEGACY_COMMENT, todays_keyword_comment(60)] + assert fake.labeled(961) + assert [args[0] if args[0] == "pr" else args[3] for args, _ in fake.writes()] == ["POST", "POST", "POST"] + # A later edit finds both comments and writes nothing. + fake.calls.clear() + code, _, _ = run_main(monkeypatch, capsys, fake, EVENT_NAME="pull_request_target", EVENT_ACTION="edited", + PR_NUMBER="961") + assert code == 0 and fake.writes() == [] diff --git a/tools/pr_intake.py b/tools/pr_intake.py index 3397f9e7..6c0f0794 100644 --- a/tools/pr_intake.py +++ b/tools/pr_intake.py @@ -46,6 +46,8 @@ DATA_PREFIX = "") KNOWN_BOTS = frozenset({"dependabot[bot]", "github-actions[bot]"}) # Pull requests opened before this date are never held. The rollout commit sets the real @@ -151,6 +153,9 @@ class Facts: mode: str # "legacy" or "enforced" sweep: bool event_action: str | None + # The keyword warning markers already on the bot's own comments. The bot owns them, so, + # like the queue label, they stay out of the hash. Only legacy mode reads them. + keyword_markers: frozenset[str] = frozenset() @dataclass(frozen=True) @@ -163,6 +168,8 @@ class Decision: data: dict[str, str] | None disable_auto_merge: bool notices: tuple[str, ...] + # Legacy mode only: today's keyword warning, posted as a comment of its own. + warning: str | None = None def parse_data_line(body: str) -> dict[str, str] | None: @@ -237,6 +244,7 @@ def facts_hash(facts: Facts, today: date) -> str: freshness, already carried by the derived boolean. `sweep` and `event_action`, which describe why the run started, not the pull request. `title` and `body`, which enter only through `editorial_ticked`, so an edit that changes no fact keeps the comment. + `keyword_markers`, which the bot writes and only legacy mode reads. """ parts = { "number": facts.number, @@ -586,8 +594,29 @@ def _data( return data +def _legacy_warning(facts: Facts) -> str | None: + """Today's keyword warning comment, or None when it is not due or already posted. + + The text and the marker are today's workflow step's, character for character, so a + comment posted here reads the same as one posted before the gate, and enforced mode + adopts either. Today's step lists the numbers in `sort -u` order, which is string order. + """ + closes = _roadmap_closes(facts) + if facts.sweep or not closes: + return None + names = sorted(str(number) for number in closes) + marker = f"{LEGACY_KEYWORD_MARKER}:{','.join(names)} -->" + if marker in facts.keyword_markers: + return None + return ( + f"{marker}\nThis pull request's description says it closes{''.join(' #' + name for name in names)}," + " which is on the project roadmap. When it merges, GitHub closes that issue and the roadmap counts it" + " as delivered. Please confirm the change delivers the whole issue before merging." + ) + + def _legacy(facts: Facts, today: date) -> Decision: - """Today's behavior: queue a human's pull request once, and only on an event run.""" + """Today's behavior: queue a human's pull request once, and warn the merger, on event runs only.""" would = _enforced(facts, today) notices = ( f"Would {would.outcome} once enforced: route={would.route or 'none'}" @@ -596,16 +625,21 @@ def _legacy(facts: Facts, today: date) -> Decision: any_accepted = any( roadmap_model.ACCEPTED in ref.labels for ref in facts.references if not ref.is_pull ) + # Today's "already commented" test skips the keyword warning, so a pull request that + # carries only the warning is still queued. + bot = facts.bot_comment + commented = bot is not None and (bot.body.startswith(LEGACY_PREFIX) or MARKER in bot.body) writes = ( not facts.sweep and not any_accepted and not editorial_ticked(facts.body) - and facts.bot_comment is None + and not commented and facts.author not in KNOWN_BOTS ) + warning = _legacy_warning(facts) if writes: - return Decision("queue", None, ("no_accepted",), "set", LEGACY_COMMENT, None, False, notices) - return Decision("skip", None, (), "keep", None, None, False, notices) + return Decision("queue", None, ("no_accepted",), "set", LEGACY_COMMENT, None, False, notices, warning) + return Decision("skip", None, (), "keep", None, None, False, notices, warning) def _enforced(facts: Facts, today: date) -> Decision: @@ -1040,23 +1074,37 @@ def _latest_by(comments: list, author: str) -> datetime | None: return max((t for t in times if t is not None), default=None) -def _bot_comment(comments: list) -> BotComment | None: - """The bot's own comment. Only the bot's exact login counts, whatever another comment says.""" - mine = [ - c for c in comments +def _bot_bodies(comments: list) -> list[tuple[int, str]]: + """The id and body of every comment by the bot's exact login, in listing order.""" + return [ + (int(c["id"]), c["body"]) for c in comments if isinstance(c, dict) and isinstance(c.get("user"), dict) and c["user"].get("login") == roadmap_model.BOT_LOGIN and isinstance(c.get("body"), str) ] - for comment in mine: - if MARKER in comment["body"]: - return BotComment(int(comment["id"]), comment["body"], parse_data_line(comment["body"]), False) - for comment in mine: - body = comment["body"] - if body.startswith(LEGACY_PREFIX) or LEGACY_KEYWORD_MARKER in body: - return BotComment(int(comment["id"]), body, None, True) + + +def _bot_comment(comments: list) -> BotComment | None: + """The bot's own comment. Only the bot's exact login counts, whatever another comment says. + + A marked comment wins, then a legacy queue comment, then a legacy keyword warning, so + a pull request carrying both legacy comments adopts the one that queued it. + """ + mine = _bot_bodies(comments) + for comment_id, body in mine: + if MARKER in body: + return BotComment(comment_id, body, parse_data_line(body), False) + for test in (lambda body: body.startswith(LEGACY_PREFIX), lambda body: LEGACY_KEYWORD_MARKER in body): + for comment_id, body in mine: + if test(body): + return BotComment(comment_id, body, None, True) return None +def _keyword_markers(comments: list) -> frozenset[str]: + """Every whole keyword warning marker on the bot's own comments.""" + return frozenset(marker for _id, body in _bot_bodies(comments) for marker in _KEYWORD_MARKER.findall(body)) + + def _last_unlabel(timeline: list) -> tuple[str, datetime] | None: """The newest removal of the queue label and who made it. @@ -1202,6 +1250,7 @@ def _assemble( last_unlabel=_last_unlabel(timeline), last_author_comment_at=_latest_by(comments, author), bot_comment=_bot_comment(comments), + keyword_markers=_keyword_markers(comments), auto_merge=pull.get("auto_merge") is not None, author_record=author_record, held_use=_held_use(number, author, references, listings), @@ -1256,6 +1305,11 @@ def write(name: str, args: list[str], payload: dict | None = None) -> None: path = f"{_API}/issues/{number}/comments" write("comment_post", ["api", "-i", "-X", "POST", path, "--input", "-"], {"body": decision.comment}) + if decision.warning is not None: + # Today's workflow posts the keyword warning as a comment of its own, so legacy mode does too. + path = f"{_API}/issues/{number}/comments" + write("keyword_post", ["api", "-i", "-X", "POST", path, "--input", "-"], {"body": decision.warning}) + if decision.label == "set": path = f"{_API}/issues/{number}/labels" write("label_set", ["api", "-i", "-X", "POST", path, "--input", "-"], {"labels": [roadmap_model.NEEDS_TRIAGE]}) @@ -1490,7 +1544,10 @@ def _assess( def _has_writes(facts: Facts, decision: Decision) -> bool: - return decision.comment is not None or decision.label != "keep" or (decision.disable_auto_merge and facts.auto_merge) + return ( + decision.comment is not None or decision.warning is not None or decision.label != "keep" + or (decision.disable_auto_merge and facts.auto_merge) + ) def _is_auto_merge_failure(exc: WriteError) -> bool: @@ -1510,7 +1567,7 @@ def _write( if issues is not None: decision = _with_seen(decision, f"{_now()},{issues}") merge_off = decision.disable_auto_merge and facts.auto_merge - merge_only = replace(decision, comment=None, label="keep") + merge_only = replace(decision, comment=None, warning=None, label="keep") try: done = apply(counter.wrap(run), facts, replace(decision, disable_auto_merge=False), pace=PACE_SECONDS) except (WriteError, BudgetStop): From 99428cc07da1041531c08ab4c7a3a84ac83ac7c3 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 22:25:07 -0600 Subject: [PATCH 20/31] Count queue days from the data line's since, not from the pull request's age Signed-off-by: rocklambros --- tests/test_roadmap_sync_health.py | 21 +++++++++++---------- tools/roadmap_sync.py | 14 +++++++++----- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/tests/test_roadmap_sync_health.py b/tests/test_roadmap_sync_health.py index 76e99c77..d2080cf6 100644 --- a/tests/test_roadmap_sync_health.py +++ b/tests/test_roadmap_sync_health.py @@ -68,12 +68,13 @@ def issue(number, labels=(), state="open", milestone=1, reason=None, title="SECR "unmilestoned_open": [issue(60, ["scope:in-focus"], milestone=None), issue(61, ["status:accepted"], milestone=None)], "facts": {10: landed("rocklambros"), 30: landed("outsider"), 41: landed("rocklambros"), 50: {"closedBy": "outsider"}}, "bot_accepted": [[20, "rocklambros"]], - # Created dates give 42, 22, 7, and 2 days on 2026-11-01. Pull request 104 carries the - # label and no data line. + # The queue clock is `since`, which gives 37, 20, and 7 days on 2026-11-01. The created + # dates are older, as for a pull request opened before the rule, and must not be read. + # Pull request 104 carries the label and no data line. "queued_pulls": [ - [101, "no_accepted", "2026-09-21", "62:2026-10-02", "2026-09-20T09:00:00Z"], - [102, "awaiting_rereview", "2026-10-25", "", "2026-10-25T09:00:00Z"], - [103, "sent_back", "2026-10-10", "", "2026-10-10T09:00:00Z"], + [101, "no_accepted", "2026-09-25", "62:2026-10-02", "2026-08-20T09:00:00Z"], + [102, "awaiting_rereview", "2026-10-25", "", "2026-09-25T09:00:00Z"], + [103, "sent_back", "2026-10-12", "", "2026-10-10T09:00:00Z"], [104, None, None, "", "2026-10-30T09:00:00Z"], ], "intake_claims": [[62, 30, 101]], @@ -120,13 +121,13 @@ def test_sections(): ] assert r["closed_nothing_done"] == [5] assert r["queued_pulls"] == [ - [101, "no_accepted", 42, "62:2026-10-02"], [103, "sent_back", 22, ""], [102, "awaiting_rereview", 7, ""], + [101, "no_accepted", 37, "62:2026-10-02"], [103, "sent_back", 20, ""], [102, "awaiting_rereview", 7, ""], ] assert r["queued_unrecorded"] == [104] assert r["queued_meta"] == ["success", 555, "2026-11-01T03:00:00Z"] assert r["queued_unreadable"] == 0 assert r["intake_counts"] == { - "median_age_days": 22, "hand_cleared_60d": 1, "non_roster_merges_60d": 1, + "median_age_days": 20, "hand_cleared_60d": 1, "non_roster_merges_60d": 1, "non_roster_merges_prior_60d": 1, "claims_waiting": 1, } @@ -413,7 +414,7 @@ def intake_body(gh, today=date(2026, 11, 1), trusted=TRUSTED, failed=()): def test_queued_section_renders_numbers_reasons_days_only(): gh = IntakeGitHub( - labeled=[labeled_pull(301)], + labeled=[labeled_pull(301, created="2026-08-01T00:00:00Z")], comments={301: [intake_comment("")]}, runs={"workflow_runs": [{"id": 777, "conclusion": "success", "created_at": "2026-11-01T03:00:00Z"}]}, ) @@ -465,9 +466,9 @@ def test_queued_headings_for_thirty_days_awaiting_sent_back_and_unrecorded(): for heading in ("Queued more than 30 days", "Awaiting re-review", "Sent back, no response", "Labeled without a gate record"): assert heading in section after = lambda heading: section.split(heading, 1)[1].split("\n\n", 2)[1] # noqa: E731 - assert after("Queued more than 30 days").splitlines() == ["- #101: no_accepted, 42 days"] + assert after("Queued more than 30 days").splitlines() == ["- #101: no_accepted, 37 days"] assert after("Awaiting re-review").splitlines() == ["- #102: awaiting_rereview, 7 days"] - assert after("Sent back, no response").splitlines() == ["- #103: sent_back, 22 days"] + assert after("Sent back, no response").splitlines() == ["- #103: sent_back, 20 days"] assert after("Labeled without a gate record").splitlines() == ["- #104"] # Grouped by reason, in the gate's reason order. assert section.index("- #101: no_accepted") < section.index("- #103: sent_back") < section.index("- #102: awaiting_rereview") diff --git a/tools/roadmap_sync.py b/tools/roadmap_sync.py index bcd10ea2..96f33245 100644 --- a/tools/roadmap_sync.py +++ b/tools/roadmap_sync.py @@ -603,13 +603,17 @@ def _intake_report(snapshot: dict, today: date) -> dict: unrecorded: list[int] = [] unreadable = 0 for number, reason, since, claimed, created in snapshot.get("queued_pulls") or []: - opened = _when(created) - if reason is None and opened is not None: - unrecorded.append(int(number)) - elif opened is None or not _readable_queue(reason, since, claimed): + if reason is None: + if _when(created) is not None: + unrecorded.append(int(number)) + else: + unreadable += 1 + elif not _readable_queue(reason, since, claimed): unreadable += 1 else: - queued.append([int(number), reason, (today - opened.date()).days, claimed]) + # The queue clock is the data line's `since`, the first queue, not the pull + # request's age. A pull request opened before the rule starts at its first sweep. + queued.append([int(number), reason, (today - _iso_day(since)).days, claimed]) queued.sort(key=lambda row: (-row[2], row[0])) def merged_age(row: list) -> int | None: From 2f854fb54a7b49edd9b171afbe4df667c910a74d Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 22:26:32 -0600 Subject: [PATCH 21/31] Read the rule's landing date from PR_INTAKE_LANDED instead of a placeholder constant Signed-off-by: rocklambros --- .github/workflows/pr-intake.yml | 10 ++++-- tests/test_pr_intake.py | 29 +++++++++++++-- tests/test_pr_intake_workflow.py | 2 ++ tools/pr_intake.py | 61 ++++++++++++++++++++++++-------- 4 files changed, 81 insertions(+), 21 deletions(-) diff --git a/.github/workflows/pr-intake.yml b/.github/workflows/pr-intake.yml index dd8a8a57..f0b14490 100644 --- a/.github/workflows/pr-intake.yml +++ b/.github/workflows/pr-intake.yml @@ -7,15 +7,18 @@ # base repository's token, so the job checks out the pull request's base ref, never the # head. main, integration, and release/* are ruleset-protected, and the job's if: admits # no other base, so only code a lead approved runs beside the token. The tool reads -# titles, bodies, and comments through the API as data. Its env carries the number, the -# event name and action, the dispatch inputs, and two repository variables, nothing else. +# titles, bodies, and comments through the API as data. Its env carries GH_TOKEN, the +# number, the event name and action, the dispatch inputs, and three repository variables, +# nothing else. # # The nightly sweep runs at 03:10 UTC so the 04:10 roadmap sweep can report the same # night's pass. That ordering is best effort: roadmap-refresh.yml records scheduler start # delays of up to two hours on this repository. # # PR_INTAKE_ENFORCED and PR_INTAKE_TOOL_SHA arm enforcement. Unsetting PR_INTAKE_ENFORCED -# is the kill switch, and the tool then runs only the legacy pass. +# is the kill switch, and the tool then runs only the legacy pass. PR_INTAKE_LANDED is the +# day enforcement started, set in the same sitting. It changes one sentence of the comment +# on older pull requests and arms nothing. # tests/test_pr_intake_workflow.py pins this file. name: PR intake @@ -86,4 +89,5 @@ jobs: EVENT_ACTION: ${{ github.event.action }} PR_INTAKE_ENFORCED: ${{ vars.PR_INTAKE_ENFORCED }} PR_INTAKE_TOOL_SHA: ${{ vars.PR_INTAKE_TOOL_SHA }} + PR_INTAKE_LANDED: ${{ vars.PR_INTAKE_LANDED }} run: python3 -I -S tools/pr_intake.py diff --git a/tests/test_pr_intake.py b/tests/test_pr_intake.py index f7d638b7..16b7aa3d 100644 --- a/tests/test_pr_intake.py +++ b/tests/test_pr_intake.py @@ -520,9 +520,12 @@ def test_legacy_queues_a_pull_request_whose_only_bot_comment_is_the_keyword_warn def test_pre_rule_pull_request_comment_says_the_rule_is_new(): - old = decide(facts(created_at=datetime(2026, 10, 1, tzinfo=timezone.utc)), TODAY) + landed = date(2026, 11, 1) + old = decide(facts(created_at=datetime(2026, 10, 1, tzinfo=timezone.utc), rule_landed=landed), TODAY) assert "The rule is new, and its 30-day clock starts on 2026-11-10." in old.comment - assert "The rule is new" not in decide(facts(), TODAY).comment + assert "The rule is new" not in decide(facts(rule_landed=landed), TODAY).comment + # With no landing date set, no pull request is treated as opened before the rule. + assert "The rule is new" not in decide(facts(created_at=datetime(2026, 10, 1, tzinfo=timezone.utc)), TODAY).comment def test_keyword_warning_present_when_a_closing_keyword_names_a_milestoned_issue(): @@ -650,6 +653,7 @@ def _mutations(base: Facts) -> dict: "sweep": True, "event_action": "edited", "keyword_markers": frozenset({""}), + "rule_landed": date(2027, 1, 1), } @@ -1337,7 +1341,7 @@ def test_apply_falls_back_to_draft_when_auto_merge_survives_the_disable(): BUMPED = "2026-11-10T03:00:05Z" ENV_KEYS = ( "PR_NUMBER", "INTAKE_MODE", "INTAKE_AUTHORS", "EVENT_NAME", "EVENT_ACTION", "GITHUB_STEP_SUMMARY", - "PR_INTAKE_ENFORCED", "PR_INTAKE_TOOL_SHA", "GH_TOKEN", + "PR_INTAKE_ENFORCED", "PR_INTAKE_TOOL_SHA", "PR_INTAKE_LANDED", "GH_TOKEN", ) OPEN_PULLS = f"{API}/pulls?state=open&per_page=100" MISMATCH = ( @@ -2002,3 +2006,22 @@ def test_legacy_event_run_on_a_milestoned_close_writes_what_todays_workflow_writ code, _, _ = run_main(monkeypatch, capsys, fake, EVENT_NAME="pull_request_target", EVENT_ACTION="edited", PR_NUMBER="961") assert code == 0 and fake.writes() == [] + + +@pytest.mark.parametrize("landed, said, warned", [ + ("2026-11-05", True, False), + ("", False, False), + ("2026-13-01", False, True), + ("soon", False, True), +]) +def test_the_landing_date_comes_from_the_repository_variable(monkeypatch, capsys, landed, said, warned): + fake = FakeGitHub([gh_pull(971)]) + code, out, _ = run_main(monkeypatch, capsys, fake, EVENT_NAME="pull_request_target", EVENT_ACTION="opened", + PR_NUMBER="971", PR_INTAKE_LANDED=landed, **armed_env()) + assert code == 0, out + (body,) = fake.bodies(971) + assert ("This pull request was opened before the intake rule took effect." in body) is said + assert ("::warning::PR_INTAKE_LANDED" in out) is warned + assert out.count("::warning::") == int(warned) + if warned: + assert landed not in out diff --git a/tests/test_pr_intake_workflow.py b/tests/test_pr_intake_workflow.py index b52944cc..571271b9 100644 --- a/tests/test_pr_intake_workflow.py +++ b/tests/test_pr_intake_workflow.py @@ -48,6 +48,7 @@ "EVENT_ACTION": "${{ github.event.action }}", "PR_INTAKE_ENFORCED": "${{ vars.PR_INTAKE_ENFORCED }}", "PR_INTAKE_TOOL_SHA": "${{ vars.PR_INTAKE_TOOL_SHA }}", + "PR_INTAKE_LANDED": "${{ vars.PR_INTAKE_LANDED }}", } # The fake gh. A shell wrapper starts it, because gh_runner hands the process an @@ -119,6 +120,7 @@ def event(number: int, action: str = "opened", name: str = "pull_request_target" "github.event.action": action, "vars.PR_INTAKE_ENFORCED": "", "vars.PR_INTAKE_TOOL_SHA": "", + "vars.PR_INTAKE_LANDED": "", } diff --git a/tools/pr_intake.py b/tools/pr_intake.py index 6c0f0794..0ec1426a 100644 --- a/tools/pr_intake.py +++ b/tools/pr_intake.py @@ -50,10 +50,11 @@ _KEYWORD_MARKER = re.compile(r"") KNOWN_BOTS = frozenset({"dependabot[bot]", "github-actions[bot]"}) -# Pull requests opened before this date are never held. The rollout commit sets the real -# value, the same pattern as roadmap_model.RULES_VERSION. This placeholder keeps the gate -# inert until then. -RULE_LANDED = date(2026, 11, 1) +# The day enforcement started, from the PR_INTAKE_LANDED repository variable, which a lead +# sets in the same sitting as PR_INTAKE_TOOL_SHA and PR_INTAKE_ENFORCED. A queue comment on +# a pull request opened before it says the rule is new. It changes wording only, so it is +# not in the facts hash and does not arm anything. +_LANDED = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}") # Budgets that bound API work per run. The reserve keeps room for the writes at the end. MAX_REFERENCES = 20 @@ -156,6 +157,8 @@ class Facts: # The keyword warning markers already on the bot's own comments. The bot owns them, so, # like the queue label, they stay out of the hash. Only legacy mode reads them. keyword_markers: frozenset[str] = frozenset() + # PR_INTAKE_LANDED, or None when it is unset or unreadable. Wording only, so not hashed. + rule_landed: date | None = None @dataclass(frozen=True) @@ -244,7 +247,8 @@ def facts_hash(facts: Facts, today: date) -> str: freshness, already carried by the derived boolean. `sweep` and `event_action`, which describe why the run started, not the pull request. `title` and `body`, which enter only through `editorial_ticked`, so an edit that changes no fact keeps the comment. - `keyword_markers`, which the bot writes and only legacy mode reads. + `keyword_markers`, which the bot writes and only legacy mode reads. `rule_landed`, a + repository variable that changes one sentence and no decision. """ parts = { "number": facts.number, @@ -835,7 +839,7 @@ def render_comment(facts: Facts, decision: Decision, today: date) -> str: "The queue label was removed by someone other than a lead, so the bot set it again." " A lead's removal clears the queue." ) - if facts.created_at.date() < RULE_LANDED: + if facts.rule_landed is not None and facts.created_at.date() < facts.rule_landed: since = data.get("since", today.isoformat()) paragraphs.append( "This pull request was opened before the intake rule took effect." @@ -1168,6 +1172,7 @@ def _held_use(number: int, author: str, refs: tuple[Reference, ...], listings: L def collect_facts( run: Runner, number: int, *, listings: Listings, roster: frozenset[str], mode: str, sweep: bool, event_action: str | None, counter: CallCounter, pull: dict | None = None, comments: list | None = None, + landed: date | None = None, ) -> Facts: """Read one pull request's facts: five reads, at most five claim reads, and the record. @@ -1186,7 +1191,7 @@ def collect_facts( timeline = paginate(run, f"{_API}/issues/{number}/timeline?per_page=100") try: return _assemble(run, number, pull, files, raw_reviews, comments, timeline, listings=listings, roster=roster, - mode=mode, sweep=sweep, event_action=event_action) + mode=mode, sweep=sweep, event_action=event_action, landed=landed) except (KeyError, TypeError, ValueError) as exc: raise RunnerError(f"pull request {number} had a response in an unexpected shape: {type(exc).__name__}") from None @@ -1194,6 +1199,7 @@ def collect_facts( def _assemble( run: Runner, number: int, pull: dict, files: list, raw_reviews: list, comments: list, timeline: list, *, listings: Listings, roster: frozenset[str], mode: str, sweep: bool, event_action: str | None, + landed: date | None, ) -> Facts: author = _login(pull.get("user")) if author is None: @@ -1258,6 +1264,7 @@ def _assemble( mode=mode, sweep=sweep, event_action=event_action, + rule_landed=landed, ) @@ -1409,6 +1416,24 @@ def tool_sha(repo: Path) -> str: return hashlib.sha256(blobs.encode("ascii")).hexdigest()[:40] +def rule_landed(env) -> date | None: + """PR_INTAKE_LANDED as a date. Unset or unreadable means no pull request predates the rule. + + An unreadable value prints one warning and never its own text, which a repository + variable could make arbitrary. + """ + raw = (env.get("PR_INTAKE_LANDED") or "").strip() + if not raw: + return None + if _LANDED.fullmatch(raw): + try: + return date.fromisoformat(raw) + except ValueError: + pass + print("::warning::PR_INTAKE_LANDED is not a YYYY-MM-DD date, so no pull request is treated as opened before the rule.") + return None + + def arming(env, repo: Path) -> str: """"enforced" when both variables arm this exact code, "mismatched" when only the switch is on, and "legacy" otherwise.""" @@ -1525,7 +1550,7 @@ class _Assessment: def _assess( run: Runner, number: int, *, listings: Listings, roster: frozenset[str], mode: str, today: date, sweep: bool, - event_action: str | None, counter: CallCounter, short_circuit: bool, + event_action: str | None, counter: CallCounter, short_circuit: bool, landed: date | None = None, ) -> _Assessment: counted = counter.wrap(run) comments = paginate(counted, f"{_API}/issues/{number}/comments?per_page=100") @@ -1538,7 +1563,7 @@ def _assess( return _Assessment(number, None, None, pull.get("updated_at"), stored["reason"], issues) facts = collect_facts( run, number, listings=listings, roster=roster, mode=mode, sweep=sweep, event_action=event_action, - counter=counter, pull=pull, comments=comments, + counter=counter, pull=pull, comments=comments, landed=landed, ) return _Assessment(number, facts, decide(facts, today), pull.get("updated_at"), None, issues) @@ -1583,12 +1608,12 @@ def _write( def evaluate_one( run: Runner, number: int, *, listings: Listings, roster: frozenset[str], mode: str, today: date, sweep: bool, - event_action: str | None, counter: CallCounter, write: bool, + event_action: str | None, counter: CallCounter, write: bool, landed: date | None = None, ) -> tuple[Decision, list[str]]: """Read, decide, and, when `write`, apply one pull request. Returns the writes made.""" assessment = _assess( run, number, listings=listings, roster=roster, mode=mode, today=today, sweep=sweep, - event_action=event_action, counter=counter, short_circuit=False, + event_action=event_action, counter=counter, short_circuit=False, landed=landed, ) facts, decision = assessment.facts, assessment.decision if not write or not _has_writes(facts, decision): @@ -1644,6 +1669,7 @@ def _count(summary: Summary, facts: Facts, decision: Decision) -> None: def run_sweep( run: Runner, numbers: list[int] | None, *, mode: str, write: bool, listings: Listings, roster: frozenset[str], today: date, counter: CallCounter | None = None, arming_mode: str | None = None, sha: str = "", log=print, + landed: date | None = None, ) -> Summary: """Evaluate every open pull request, or `numbers`, then make the writes in a second pass. @@ -1677,7 +1703,7 @@ def run_sweep( run, number, listings=listings, roster=roster, mode=mode, today=today, sweep=True, # A dry run decides every pull request, so it shows what enforcement would do # today rather than what a data line recorded. - event_action=None, counter=counter, short_circuit=mode == "enforced" and write, + event_action=None, counter=counter, short_circuit=mode == "enforced" and write, landed=landed, )) except BudgetStop: summary.stopped = True @@ -1835,6 +1861,7 @@ def log(text: str) -> None: def _main_event( run: Runner, number: int, *, state: str, action: str | None, roster: frozenset[str], today: date, + landed: date | None = None, ) -> int: """One pull request on its own event. Green on anything but a failed auto-merge disable.""" if state == "mismatched": @@ -1853,7 +1880,7 @@ def _main_event( listings = load_listings(counted) decision, done = evaluate_one( run, number, listings=listings, roster=roster, mode=mode, today=today, sweep=False, - event_action=action, counter=counter, write=True, + event_action=action, counter=counter, write=True, landed=landed, ) except WriteError as exc: if _is_auto_merge_failure(exc): @@ -1872,6 +1899,7 @@ def _main_event( def _main_sweep( run: Runner, number: int | None, *, state: str, intake: str, roster: frozenset[str], today: date, sha: str, + landed: date | None = None, ) -> int: """Every open pull request, or the one dispatched. Red on any error or an early stop.""" dry = intake == "dryrun" @@ -1887,6 +1915,7 @@ def _main_sweep( summary = run_sweep( run, None if number is None else [number], mode=mode, write=not dry and mode == "enforced", listings=listings, roster=roster, today=today, counter=counter, arming_mode=state, sha=sha, log=log, + landed=landed, ) red = bool(summary.errors) or summary.stopped if state == "mismatched" and not dry: @@ -1951,9 +1980,11 @@ def main(argv: list[str] | None = None) -> int: # Without the roster every lead reads as an outsider, so nothing is decided at all. print(f"::warning::The roster did not parse ({type(exc).__name__}), so nothing was evaluated or written.") return 0 if event in EVENT_NAMES else 1 + landed = rule_landed(env) if event in EVENT_NAMES and intake == "evaluate": - return _main_event(run, number, state=state, action=env.get("EVENT_ACTION") or None, roster=roster, today=today) - return _main_sweep(run, number, state=state, intake=intake, roster=roster, today=today, sha=sha) + return _main_event(run, number, state=state, action=env.get("EVENT_ACTION") or None, roster=roster, today=today, + landed=landed) + return _main_sweep(run, number, state=state, intake=intake, roster=roster, today=today, sha=sha, landed=landed) if __name__ == "__main__": From fa3cf2c74b2668704c500cd23b5dfc7379780e47 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 22:27:30 -0600 Subject: [PATCH 22/31] Read each lead's latest verdict so a later request for changes outranks an approval Signed-off-by: rocklambros --- tests/test_pr_intake.py | 36 +++++++++++++++++++++++++++++++++-- tools/pr_intake.py | 42 ++++++++++++++++++++++++++--------------- 2 files changed, 61 insertions(+), 17 deletions(-) diff --git a/tests/test_pr_intake.py b/tests/test_pr_intake.py index 16b7aa3d..01ff85b7 100644 --- a/tests/test_pr_intake.py +++ b/tests/test_pr_intake.py @@ -276,7 +276,32 @@ def test_push_after_changes_requested_is_awaiting_rereview_not_sent_back(): assert decision.reasons[0] == "awaiting_rereview" assert "sent_back" not in decision.reasons assert decision.data["reason"] == "awaiting_rereview" - assert "re-review" in decision.comment or "second review" in decision.comment + assert ("A lead requested changes more than 30 days ago, and this pull request has changed since. It is" + " waiting on the lead's re-review.") in decision.comment + + +def test_a_leads_latest_verdict_stands_over_their_earlier_one(): + approved, asked = review(state="APPROVED", days=60), review(state="CHANGES_REQUESTED", days=40) + sent_back = decide(facts(reviews=(approved, asked)), TODAY) + assert outcome(sent_back) == ("queue", None, ("sent_back", "no_accepted")) + # Listing order does not matter, the submitted time does. + assert outcome(decide(facts(reviews=(asked, approved)), TODAY)) == outcome(sent_back) + # Another lead's approval still stands beside it. + other = review(login="lrd0036", state="APPROVED", days=50) + assert outcome(decide(facts(reviews=(approved, asked, other)), TODAY)) == ("pass", "review", ()) + # A COMMENTED review is a thread reply. It neither grants nor takes back a verdict. + replied = review(state="COMMENTED", days=10) + assert outcome(decide(facts(reviews=(approved, replied)), TODAY)) == ("pass", "review", ()) + + +def test_engaged_by_fresh_review_reads_only_lead_reviews(): + fresh = review(state="CHANGES_REQUESTED", days=3) + assert pr_intake.engaged_by_fresh_review(facts(reviews=(fresh,)), TODAY) + for outsider in (review(login="someone", state="CHANGES_REQUESTED", days=3), + review(state="CHANGES_REQUESTED", days=3, association="CONTRIBUTOR")): + assert not pr_intake.engaged_by_fresh_review(facts(reviews=(outsider,)), TODAY) + # Superseded by the same lead's approval, the request no longer keeps anything fresh. + assert not pr_intake.engaged_by_fresh_review(facts(reviews=(fresh, review(days=1))), TODAY) def test_hand_clear_records_the_head_and_holds_while_it_matches(): @@ -1708,11 +1733,18 @@ def full_read(fake_) -> bool: def test_review_pass_expires_and_the_short_circuit_stops_on_that_day(): - stale_review = review(state="CHANGES_REQUESTED", days=5) + stale_review = review(login="lrd0036", state="CHANGES_REQUESTED", days=5) approved = review(state="APPROVED", days=2) passed = decide(facts(reviews=(approved, stale_review)), TODAY) assert outcome(passed) == ("pass", "review", ()) assert passed.data["expires"] == (TODAY - timedelta(days=5) + timedelta(days=30)).isoformat() + # An approval never goes stale, so a pass on approvals alone carries no expiry and the + # short circuit holds on it night after night. + approval_only = decide(facts(reviews=(approved,)), TODAY) + assert outcome(approval_only) == ("pass", "review", ()) and "expires" not in approval_only.data + # The same lead approving after their own request for changes leaves only the approval. + superseded = decide(facts(reviews=(review(state="CHANGES_REQUESTED", days=5), approved)), TODAY) + assert outcome(superseded) == ("pass", "review", ()) and "expires" not in superseded.data stored = {"reason": "cleared", "route": "review", "seen": f"{WRITE_TIME},none", "expires": "2026-11-20"} pull = {"updated_at": BUMPED, "labels": []} diff --git a/tools/pr_intake.py b/tools/pr_intake.py index 0ec1426a..c80f77de 100644 --- a/tools/pr_intake.py +++ b/tools/pr_intake.py @@ -207,14 +207,8 @@ def editorial_ticked(body: str) -> bool: def engaged_by_fresh_review(facts: Facts, today: date) -> bool: - """Whether a CHANGES_REQUESTED review is recent enough to keep the pull request engaged.""" - cutoff = today - timedelta(days=FRESH_REVIEW_DAYS) - return any( - review.state == "CHANGES_REQUESTED" - and review.submitted_at is not None - and review.submitted_at.date() >= cutoff - for review in facts.reviews - ) + """Whether a lead's standing CHANGES_REQUESTED review is recent enough to keep it engaged.""" + return any(review.state == "CHANGES_REQUESTED" and _fresh(review, today) for review in _lead_reviews(facts)) def _by_bot(unlabel: tuple[str, datetime] | None) -> bool: @@ -331,9 +325,27 @@ def _on_roster(login: str, facts: Facts) -> bool: return login.casefold() in facts.roster +VERDICTS = frozenset({"APPROVED", "CHANGES_REQUESTED"}) +_NEVER = datetime.min.replace(tzinfo=timezone.utc) + + def _lead_reviews(facts: Facts) -> list[Review]: - """Reviews that speak for the project: a roster login that also holds a repository role.""" - return [r for r in facts.reviews if _on_roster(r.login, facts) and r.association in LEAD_ASSOCIATIONS] + """Reviews that speak for the project: a roster login that also holds a repository role. + + Each lead's latest verdict stands and their earlier ones drop, so an approval followed by + a request for changes reads as the request. A COMMENTED review is a thread reply. It is + kept, because the staleness check reads it as the lead looking again, but it neither + grants nor takes back a verdict. Ties on the submitted time go to the later listing. + """ + lead = [r for r in facts.reviews if _on_roster(r.login, facts) and r.association in LEAD_ASSOCIATIONS] + latest: dict[str, Review] = {} + for review in lead: + if review.state not in VERDICTS: + continue + held = latest.get(review.login) + if held is None or (review.submitted_at or _NEVER) >= (held.submitted_at or _NEVER): + latest[review.login] = review + return [r for r in lead if r.state not in VERDICTS or latest[r.login] is r] def _fresh(review: Review, today: date) -> bool: @@ -548,15 +560,15 @@ def _hash_as_read_next(facts: Facts, cleared_head: str | None, today: date) -> s def _review_expires(facts: Facts, today: date) -> str | None: - """The day a review pass may stop holding: the earliest qualifying lead review plus 30 days. + """The day a review pass may stop holding: the earliest fresh changes request plus 30 days. - The earliest is the conservative choice. A sweep that re-reads early costs reads, and - one that re-reads late could miss a changes-requested review going stale. + An approval never goes stale, so it sets no expiry. The earliest request is the + conservative choice. A sweep that re-reads early costs reads, and one that re-reads late + could miss a changes-requested review going stale. """ dates = [ review.submitted_at.date() for review in _lead_reviews(facts) - if review.submitted_at is not None - and (review.state == "APPROVED" or (review.state == "CHANGES_REQUESTED" and _fresh(review, today))) + if review.submitted_at is not None and review.state == "CHANGES_REQUESTED" and _fresh(review, today) ] return (min(dates) + timedelta(days=FRESH_REVIEW_DAYS)).isoformat() if dates else None From 09ea9e062874b7fb8c0a1403bb0d91c71d4d168d Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 22:28:19 -0600 Subject: [PATCH 23/31] Decide a fork's review run read-only and leave the writes to the nightly sweep Signed-off-by: rocklambros --- .github/workflows/pr-intake.yml | 5 +++++ tests/test_pr_intake.py | 26 ++++++++++++++++++++++++-- tools/pr_intake.py | 22 +++++++++++++++++++--- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr-intake.yml b/.github/workflows/pr-intake.yml index f0b14490..90bfd988 100644 --- a/.github/workflows/pr-intake.yml +++ b/.github/workflows/pr-intake.yml @@ -11,6 +11,11 @@ # number, the event name and action, the dispatch inputs, and three repository variables, # nothing else. # +# pull_request_review gets a read-only token when the pull request comes from a fork, so +# on a fork's pull request the tool decides, writes nothing, prints a notice, and exits 0, +# and the nightly sweep applies the decision. A same-repository pull +# request is cleared on the review itself. +# # The nightly sweep runs at 03:10 UTC so the 04:10 roadmap sweep can report the same # night's pass. That ordering is best effort: roadmap-refresh.yml records scheduler start # delays of up to two hours on this repository. diff --git a/tests/test_pr_intake.py b/tests/test_pr_intake.py index 01ff85b7..83ef66fb 100644 --- a/tests/test_pr_intake.py +++ b/tests/test_pr_intake.py @@ -1376,11 +1376,12 @@ def test_apply_falls_back_to_draft_when_auto_merge_survives_the_disable(): def gh_pull(number, *, author="outsider", body="b", draft=False, labels=(), updated="2026-11-09T01:00:00Z", - auto_merge=None) -> dict: + auto_merge=None, head_repo=roadmap_model.REPO) -> dict: return { "number": number, "state": "open", "draft": draft, "title": "Add a thing", "body": body, "user": {"login": author, "type": "Bot" if author.endswith("[bot]") else "User"}, - "base": {"ref": "integration"}, "head": {"sha": f"{number:040d}"}, + "base": {"ref": "integration"}, + "head": {"sha": f"{number:040d}", "repo": {"full_name": head_repo} if head_repo else None}, "created_at": "2026-11-02T12:00:00Z", "updated_at": updated, "changed_files": 1, "labels": [{"name": name} for name in labels], "auto_merge": auto_merge, } @@ -2057,3 +2058,24 @@ def test_the_landing_date_comes_from_the_repository_variable(monkeypatch, capsys assert out.count("::warning::") == int(warned) if warned: assert landed not in out + + +@pytest.mark.parametrize("head_repo", ["someone/agent-control-standard", None]) +def test_review_run_on_a_fork_writes_nothing_and_leaves_it_to_the_sweep(monkeypatch, capsys, head_repo): + # A fork's review run holds a read-only token, so the disable would fail and turn the run red. + fake = FakeGitHub([gh_pull(981, auto_merge={"merge_method": "squash"}, head_repo=head_repo)]) + code, out, _ = run_main(monkeypatch, capsys, fake, EVENT_NAME="pull_request_review", EVENT_ACTION="submitted", + PR_NUMBER="981", **armed_env()) + assert code == 0, out + assert fake.writes() == [] + assert "#981 queue no_accepted" in out + assert "::notice::#981: a review run on a fork's pull request has a read-only token, so the nightly sweep applies this decision." in out + assert "::error::" not in out + + +def test_review_run_on_a_same_repository_pull_request_writes(monkeypatch, capsys): + fake = FakeGitHub([gh_pull(982, auto_merge={"merge_method": "squash"})]) + code, out, _ = run_main(monkeypatch, capsys, fake, EVENT_NAME="pull_request_review", EVENT_ACTION="submitted", + PR_NUMBER="982", **armed_env()) + assert code == 0, out + assert fake.labeled(982) and fake.bodies(982) and fake.pulls[982]["auto_merge"] is None diff --git a/tools/pr_intake.py b/tools/pr_intake.py index c80f77de..1bcf029c 100644 --- a/tools/pr_intake.py +++ b/tools/pr_intake.py @@ -1871,9 +1871,17 @@ def log(text: str) -> None: return lines, log +def _same_repository(pull: dict) -> bool: + """Whether the pull request's head lives in this repository. A deleted fork reads as a fork.""" + head = pull.get("head") + repo = head.get("repo") if isinstance(head, dict) else None + name = repo.get("full_name") if isinstance(repo, dict) else None + return isinstance(name, str) and name.casefold() == roadmap_model.REPO.casefold() + + def _main_event( run: Runner, number: int, *, state: str, action: str | None, roster: frozenset[str], today: date, - landed: date | None = None, + landed: date | None = None, event: str = "pull_request_target", ) -> int: """One pull request on its own event. Green on anything but a failed auto-merge disable.""" if state == "mismatched": @@ -1881,7 +1889,12 @@ def _main_event( mode = "enforced" if state == "enforced" else "legacy" counter = CallCounter() counted = counter.wrap(run) + write = True try: + if event == "pull_request_review" and not _same_repository(_read_pull(counted, number)): + # GitHub gives a review run on a fork's pull request a read-only token, so every + # write would fail, the auto-merge disable red. The sweep makes the same decision. + write = False if action == "synchronize": # A push changes no fact the gate reads except the paths, which only the # editorial route reads. The nightly sweep catches the head change. @@ -1892,7 +1905,7 @@ def _main_event( listings = load_listings(counted) decision, done = evaluate_one( run, number, listings=listings, roster=roster, mode=mode, today=today, sweep=False, - event_action=action, counter=counter, write=True, landed=landed, + event_action=action, counter=counter, write=write, landed=landed, ) except WriteError as exc: if _is_auto_merge_failure(exc): @@ -1904,6 +1917,9 @@ def _main_event( print(f"::notice::#{number}: {type(exc).__name__}, so nothing was written") return 0 print(_decision_line(number, decision)) + if not write: + print(f"::notice::#{number}: a review run on a fork's pull request has a read-only token, so the nightly" + " sweep applies this decision.") if done: print(f"#{number} wrote {','.join(done)}") return 0 @@ -1995,7 +2011,7 @@ def main(argv: list[str] | None = None) -> int: landed = rule_landed(env) if event in EVENT_NAMES and intake == "evaluate": return _main_event(run, number, state=state, action=env.get("EVENT_ACTION") or None, roster=roster, today=today, - landed=landed) + landed=landed, event=event) return _main_sweep(run, number, state=state, intake=intake, roster=roster, today=today, sha=sha, landed=landed) From 1eda0ac675dbc945b4647f65d36b51e888b88e8d Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 22:29:06 -0600 Subject: [PATCH 24/31] Raise on a comments listing in an unreadable shape and say so in Risks Signed-off-by: rocklambros --- design/2026-10-29-pr-intake-rules-design.md | 15 ++++++++++++--- tests/test_pr_intake.py | 12 ++++++++++++ tools/pr_intake.py | 8 ++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/design/2026-10-29-pr-intake-rules-design.md b/design/2026-10-29-pr-intake-rules-design.md index ba96ef3a..78999417 100644 --- a/design/2026-10-29-pr-intake-rules-design.md +++ b/design/2026-10-29-pr-intake-rules-design.md @@ -395,9 +395,11 @@ step: unset `PR_INTAKE_ENFORCED`, then dispatch `mode=retract`. - Claims on issues have no responder today (one in four answered, measured). The comment no longer promises one. The health issue lists claims older than 14 days as a lead agenda item. Issue #145's first-response target is the real fix and stays open. -- A runner image update changes a `gh` shape. The recorded fixtures do not catch it. The - weekly legacy-mode sweep's "facts read empty for a pull request the listing says has - data" check does, red. +- A runner image update changes a `gh` shape. The recorded fixtures do not catch it. On + the nightly sweep, a shared listing that fails to arrive, a per-pull read that fails, or + a response in a shape the tool cannot read is red. A comments listing in which no entry + has a `user` object and a `body` string is such a response, and `paths_truncated` + catches a files listing shorter than the pull request's changed-file count. - The base-ref checkout trusts the ruleset of the base it runs from. `main`, `integration`, and `release/*` are ruleset-protected. Other branches in this repository are not (26 when the plan premortem counted), and a pull request can target any of @@ -563,6 +565,13 @@ narrower rule. The roster sentence names triage volunteers with the leads and co The key phrase is "assigned to you". The CODEOWNERS exemption sentence spans two comment lines to stay inside the header's width. +Shape checks in Risks. The Risks bullet on a changed `gh` shape names the checks the tool +has: a failed shared listing, a failed per-pull read, or an unreadable response is red on +the nightly sweep, a comments listing with no entry in the REST shape raises rather than +reading as no bot comment, and `paths_truncated` catches a short files listing. The earlier +text named a weekly legacy-mode check for facts read empty, which no code performs, and the +sweep is nightly. + Draft until the owner's line. The implementing pull request opens as a draft and is marked ready for review once the owner writes the line in "Where the logic lives". This replaces "before the implementing pull request opens" in that paragraph, which is left as the owner diff --git a/tests/test_pr_intake.py b/tests/test_pr_intake.py index 83ef66fb..5fcbea18 100644 --- a/tests/test_pr_intake.py +++ b/tests/test_pr_intake.py @@ -1095,6 +1095,18 @@ def test_collect_reads_the_keyword_markers_only_the_bot_posted(): assert forged.keyword_markers == frozenset({""}) +def test_a_comments_listing_in_an_unreadable_shape_is_an_error_not_an_empty_record(): + # The GraphQL form, `author` and `text`, would otherwise read as a pull request the bot + # never commented on, and the bot would post a second comment. + reshaped = [{"id": c["id"], "author": c["user"], "text": c["body"]} for c in fixture("comments.json")] + with pytest.raises(gh_runner.RunnerError): + collect(pull_mapping(comments=reshaped)) + # An empty listing and a deleted account beside readable comments are both real shapes. + assert collect(pull_mapping(comments=[]))[0].bot_comment is None + ghost = [{"id": 1, "user": None, "body": "x"}] + fixture("comments.json") + assert collect(pull_mapping(comments=ghost))[0].bot_comment.id == 910003 + + def test_listing_failure_raises_ListingError(): for path in listing_mapping(): mapping = listing_mapping() diff --git a/tools/pr_intake.py b/tools/pr_intake.py index 1bcf029c..ecea1317 100644 --- a/tools/pr_intake.py +++ b/tools/pr_intake.py @@ -1104,7 +1104,15 @@ def _bot_comment(comments: list) -> BotComment | None: A marked comment wins, then a legacy queue comment, then a legacy keyword warning, so a pull request carrying both legacy comments adopts the one that queued it. + + A non-empty listing in which no entry has a `user` object and a `body` string is a + response in a shape this cannot read, a changed `gh` output for instance. Read as no + bot comment, it would post a second comment, so it raises RunnerError instead. """ + if comments and not any( + isinstance(c, dict) and isinstance(c.get("user"), dict) and isinstance(c.get("body"), str) for c in comments + ): + raise RunnerError("the comments listing has no entry in the REST shape") mine = _bot_bodies(comments) for comment_id, body in mine: if MARKER in body: From 0103744379f62ecd2ddb2ab43d41f31958363431 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 22:30:07 -0600 Subject: [PATCH 25/31] Run on a label removal only for the queue label, and name integration as the sweep ref Signed-off-by: rocklambros --- .github/workflows/pr-intake.yml | 12 ++++++++---- tests/test_pr_intake_workflow.py | 14 +++++++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr-intake.yml b/.github/workflows/pr-intake.yml index 90bfd988..d80f1405 100644 --- a/.github/workflows/pr-intake.yml +++ b/.github/workflows/pr-intake.yml @@ -64,14 +64,16 @@ concurrency: jobs: intake: name: PR intake - # Pull request events run only against a protected base, and an edit runs only when - # it changed the body, the title, or the base. + # Pull request events run only against a protected base, an edit runs only when it + # changed the body, the title, or the base, and a label removal runs only for the queue + # label, the one removal the gate reads. if: >- github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || ((github.event.pull_request.base.ref == 'main' || github.event.pull_request.base.ref == 'integration' || startsWith(github.event.pull_request.base.ref, 'release/')) && (github.event.action != 'edited' || github.event.changes.body || github.event.changes.title || - github.event.changes.base)) + github.event.changes.base) && + (github.event.action != 'unlabeled' || github.event.label.name == 'status:needs-triage')) runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -82,7 +84,9 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - # The base ref on pull request events. The default branch on schedule and dispatch. + # The base ref on pull request events. integration on schedule and dispatch, the ref + # whose tool a lead dry-runs to produce PR_INTAKE_TOOL_SHA, so the sweep enforces + # exactly the code that was armed. ref: ${{ github.event.pull_request.base.ref || 'integration' }} - name: Run the intake gate env: diff --git a/tests/test_pr_intake_workflow.py b/tests/test_pr_intake_workflow.py index 571271b9..3685a7f1 100644 --- a/tests/test_pr_intake_workflow.py +++ b/tests/test_pr_intake_workflow.py @@ -37,7 +37,8 @@ "((github.event.pull_request.base.ref == 'main' || github.event.pull_request.base.ref == 'integration' || " "startsWith(github.event.pull_request.base.ref, 'release/')) && " "(github.event.action != 'edited' || github.event.changes.body || github.event.changes.title || " - "github.event.changes.base))" + "github.event.changes.base) && " + "(github.event.action != 'unlabeled' || github.event.label.name == 'status:needs-triage'))" ) STEP_ENV = { "GH_TOKEN": "${{ github.token }}", @@ -280,6 +281,17 @@ def test_edited_guard_covers_body_title_and_base(): "github.event.changes.base)" in guard +def test_unlabeled_runs_only_for_the_queue_label(): + # Every other label removal would spend about twenty calls deciding nothing new. + assert "(github.event.action != 'unlabeled' || github.event.label.name == 'status:needs-triage')" in job()["if"] + + +def test_checkout_comment_names_integration_for_schedule_and_dispatch(): + text = WORKFLOW.read_text(encoding="utf-8") + assert "The base ref on pull request events. integration on schedule and dispatch" in text + assert "default branch on schedule" not in text + + def test_step_env_is_exactly_this(): assert tool_step()["env"] == STEP_ENV # The token reaches the tool's step and no other. From 25cdbaa17e4b245a336fc8e4a0843846d365a795 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 22:32:43 -0600 Subject: [PATCH 26/31] Fix the batched comment, the claim cap note, the run link, and three read edges Signed-off-by: rocklambros --- tests/test_monitor_roadmap.py | 31 +++++++++++++++-- tests/test_pr_intake.py | 58 +++++++++++++++++++++++++------ tests/test_roadmap_sync_health.py | 14 +++++++- tools/monitor_roadmap.py | 11 ++++-- tools/pr_intake.py | 45 ++++++++++++++++++------ tools/roadmap_sync.py | 7 ++-- 6 files changed, 136 insertions(+), 30 deletions(-) diff --git a/tests/test_monitor_roadmap.py b/tests/test_monitor_roadmap.py index ddba794d..1926725b 100644 --- a/tests/test_monitor_roadmap.py +++ b/tests/test_monitor_roadmap.py @@ -119,7 +119,7 @@ def sweep(conclusion="success", hours=5, status="completed", run_id=77): @pytest.mark.parametrize("runs, expected", [ - (None, ["no completed nightly intake sweep run exists"]), + (None, ["the intake run list could not be read"]), ([], ["no completed nightly intake sweep run exists"]), ([sweep(hours=31)], ["the nightly intake sweep run 77 is older than 30 hours"]), ([sweep("failure")], ["the nightly intake sweep run 77 concluded failure"]), @@ -141,7 +141,34 @@ def test_evaluate_runs_intake_only_when_enforced(): on = evaluate({"PR_INTAKE_ENFORCED": "true"}, read_json=lambda url: None, read_issue=lambda n: None, now=NOW, read_runs=lambda: None) assert off == [] - assert on == ["no completed nightly intake sweep run exists"] + assert on == ["the intake run list could not be read"] + + +@pytest.mark.parametrize("served, expected", [ + ((1, "HTTP/2.0 401 Unauthorized\r\n\r\n{\"message\": \"Bad credentials\"}", ""), None), + ((1, "HTTP/2.0 403 Forbidden\r\n\r\n{\"message\": \"Resource not accessible\"}", ""), None), + ((0, "HTTP/2.0 200 OK\r\n\r\n{\"workflow_runs\": []}", ""), []), + ((0, "HTTP/2.0 200 OK\r\n\r\n{\"workflow_runs\": [{\"id\": 7}]}", ""), [{"id": 7}]), +]) +def test_read_runs_tells_an_unreadable_list_from_an_empty_one(monkeypatch, served, expected): + import gh_runner + import monitor_roadmap + calls = [] + + def run(args, stdin=None): + gh_runner._check_allowlist(args) + calls.append(args) + return served + + monkeypatch.setattr(monitor_roadmap, "make_runner", lambda *a, **k: run) + assert monitor_roadmap._read_runs() == expected + assert calls == [["api", "-i", "repos/GenAI-Security-Project/agent-control-standard/actions/workflows/" + "pr-intake.yml/runs?event=schedule&status=completed&per_page=1"]] + problems = monitor_roadmap.check_intake(monitor_roadmap._read_runs(), NOW) + if expected is None: + assert problems == ["the intake run list could not be read"] + elif expected == []: + assert problems == ["no completed nightly intake sweep run exists"] def test_evaluate_reads_enforced_from_env(): diff --git a/tests/test_pr_intake.py b/tests/test_pr_intake.py index 5fcbea18..178e9506 100644 --- a/tests/test_pr_intake.py +++ b/tests/test_pr_intake.py @@ -465,6 +465,13 @@ def test_body_failing_two_routes_renders_both_paragraphs(): assert decision.data["issues"] == "10,11,12" +def test_comment_says_when_the_claim_reads_were_cut(): + decision = decide(facts(references=(issue(47),), held_use={47: (354,)}, claims_dropped=3), TODAY) + assert (f"The bot checked your comments on the first {pr_intake.MAX_CLAIM_READS} accepted issues this pull" + " request names, and 3 more were not checked.") in decision.comment + assert "were not checked" not in decide(facts(references=(issue(47),), held_use={47: (354,)}), TODAY).comment + + def test_prior_comment_is_named_as_possibly_waiting(): asked = issue(47, commented=OPENED - timedelta(days=4)) decision = decide(facts(references=(asked, issue(48)), held_use={47: (354,), 48: (354,)}), TODAY) @@ -679,6 +686,7 @@ def _mutations(base: Facts) -> dict: "event_action": "edited", "keyword_markers": frozenset({""}), "rule_landed": date(2027, 1, 1), + "claims_dropped": 2, } @@ -930,11 +938,14 @@ def test_changelog_anchor_is_not_a_reference(): def test_sixty_four_kilobyte_anchor_body_parses_under_one_second(): + foreign = '#92' + # Every body carries a #92 inside a foreign anchor, so the assertion below has something + # to drop, and every body is near the parse limit. bodies = ( - ("#92 ' * 2_000)[: closing_choice.MAX_CHARS], - ("#5 ") * 30, - ("#5 ") * 30 + foreign, + '#92 ' + (" FakeGitHub: gh_pull(801, author="dependabot[bot]", labels=(NEEDS_TRIAGE,)), gh_pull(802, labels=(NEEDS_TRIAGE,)), gh_pull(803, author="github-actions[bot]", labels=(NEEDS_TRIAGE,)), + gh_pull(804, author="dependabot[bot]"), ]) fake.comments[801] = [bot_said(pr_intake.LEGACY_COMMENT, 1)] fake.comments[802] = [bot_said(COMMENT, 2)] return fake fake = population() - summary = retract(fake, [801, 802, 803], authors="bots", today=SWEEP_TODAY, log=lambda line: None) - assert (summary.cleared, summary.unchanged, summary.errors) == (2, 1, []) and balanced(summary) + summary = retract(fake, [801, 802, 803, 804], authors="bots", today=SWEEP_TODAY, log=lambda line: None) + # 804 is a bot's pull request with no label and no comment: nothing to undo, so unchanged. + assert (summary.cleared, summary.unchanged, summary.errors) == (2, 2, []) and balanced(summary) + assert fake.bodies(804) == [] and not any("/804/" in args[4] for args, _ in fake.writes() if args[0] == "api") assert not fake.labeled(801) and not fake.labeled(803) and fake.labeled(802) assert parse_data_line(fake.bodies(801)[0])["reason"] == "retracted" assert fake.bodies(802) == [COMMENT] and fake.bodies(803) == [] @@ -1967,18 +1994,27 @@ def population() -> FakeGitHub: def test_any_sweep_batches_an_author_with_many_queues(): busy = [gh_pull(900 + n, author="busy") for n in range(1, 6)] + busy[1] = gh_pull(902, author="busy", auto_merge={"merge_method": "squash"}) + busy[2] = gh_pull(903, author="busy", body="Fixes #60") calm = [gh_pull(910 + n, author="calm") for n in range(1, 4)] - fake = FakeGitHub(busy + calm) + roadmap = {**gh_issue(60), "labels": [], "milestone": {"number": 1}} + fake = FakeGitHub(busy + calm, accepted=[roadmap]) summary, _ = sweep(fake) assert summary.newly_queued == 8 (newest,) = fake.bodies(905) assert pr_intake.QUEUED_LINE in newest + short = "Queued with reason `no_accepted`. PR #905 carries the bot's full comment." + extra = { + 902: ["", "Auto-merge is off while this pull request is queued."], + 903: ["", pr_intake._keyword_warning((60,))], + } for number in range(901, 905): (body,) = fake.bodies(number) lines = body.split("\n") assert parse_data_line(lines[0])["reason"] == "no_accepted" and lines[1] == pr_intake.MARKER - assert lines[2:] == ["", "Queued for the reason given on PR #905."] + assert lines[2:] == ["", short] + extra.get(number, []) assert fake.labeled(number) + assert fake.pulls[902]["auto_merge"] is None for number in range(911, 914): assert pr_intake.QUEUED_LINE in fake.bodies(number)[0] diff --git a/tests/test_roadmap_sync_health.py b/tests/test_roadmap_sync_health.py index d2080cf6..cc40341d 100644 --- a/tests/test_roadmap_sync_health.py +++ b/tests/test_roadmap_sync_health.py @@ -422,7 +422,7 @@ def test_queued_section_renders_numbers_reasons_days_only(): assert r["queued_pulls"] == [[301, "no_accepted", 31, "62:2026-10-02"]] assert "## Queued pull requests" in body assert "- #301: no_accepted, 31 days" in body - assert "the nightly intake sweep run 777 concluded success" in body + assert f"the nightly intake sweep run [777](https://github.com/{REPO}/actions/runs/777) concluded success" in body assert "- #62: commented 30 days ago by the author of PR #301 (14 days or more)" in body assert "Any comment by the author before the pull request counts, so read the issue before acting." in body assert (f"[All queued pull requests with authors](https://github.com/{REPO}/pulls?q=" @@ -592,6 +592,18 @@ def test_closed_listing_reads_the_next_page_while_inside_the_window(): assert len(snapshot["intake_recent"]) == 101 +def test_closed_listing_reads_on_past_a_pull_request_with_no_update_time(): + # A missing updated_at says nothing about the window, so it cannot end the listing. + first = [closed_pull(500 + n, "2026-10-01T00:00:00Z") for n in range(100)] + del first[50]["updated_at"] + second = [closed_pull(700, "2026-09-01T00:00:00Z")] + gh = IntakeGitHub(pages=[first, second]) + from roadmap_sync import intake_snapshot + snapshot = intake_snapshot(gh, date(2026, 11, 1), TRUSTED) + assert gh.requested("&page=") == [CLOSED_PAGE + "1", CLOSED_PAGE + "2"] + assert len(snapshot["intake_recent"]) == 101 + + def test_closed_listing_past_the_page_cap_degrades_rather_than_undercounts(): from roadmap_sync import CLOSED_PAGE_CAP, intake_snapshot full = [closed_pull(500 + n, "2026-10-01T00:00:00Z") for n in range(100)] diff --git a/tools/monitor_roadmap.py b/tools/monitor_roadmap.py index 44e4ccd4..2b0817b6 100644 --- a/tools/monitor_roadmap.py +++ b/tools/monitor_roadmap.py @@ -104,9 +104,16 @@ def check_health(configured: str, issue: dict | None, now: datetime) -> list[str def check_intake(runs: list | None, now: datetime) -> list[str]: - """Problems with the latest completed nightly intake sweep. An unfinished run is skipped.""" + """Problems with the latest completed nightly intake sweep. An unfinished run is skipped. + + None means the run list could not be read, a 401 or a 403 for instance, which is its + own problem. Reading it as no run at all would send the leads looking for a missing + schedule when the token is what failed. + """ + if runs is None: + return ["the intake run list could not be read"] finished = [ - run for run in (runs or []) + run for run in runs if isinstance(run, dict) and run.get("status") == "completed" ] if not finished: diff --git a/tools/pr_intake.py b/tools/pr_intake.py index ecea1317..3f0be53c 100644 --- a/tools/pr_intake.py +++ b/tools/pr_intake.py @@ -159,6 +159,8 @@ class Facts: keyword_markers: frozenset[str] = frozenset() # PR_INTAKE_LANDED, or None when it is unset or unreadable. Wording only, so not hashed. rule_landed: date | None = None + # Open accepted issues the author does not hold whose comments the claim-read cap left unread. + claims_dropped: int = 0 @dataclass(frozen=True) @@ -219,7 +221,7 @@ def facts_hash(facts: Facts, today: date) -> str: """Twelve hex characters that change when anything the gate reads changes. Included: number, state, draft, author, author_is_bot, base, head_sha, labels, paths, - paths_truncated, references_dropped, reviews (login, state, association, commit_id), + paths_truncated, references_dropped, claims_dropped, reviews (login, state, association, commit_id), last_unlabel, last_author_comment_at, auto_merge, author_record, held_use, roster, mode, and every reference in full. The closing set enters through `references[].kind`, which `closing_choice.parse_references` sets from the keyword, so `Closes #5` becoming @@ -268,6 +270,7 @@ def facts_hash(facts: Facts, today: date) -> str: key=repr, ), "references_dropped": facts.references_dropped, + "claims_dropped": facts.claims_dropped, "reviews": sorted(((r.login, r.state, r.association, r.commit_id) for r in facts.reviews), key=repr), "cleared_head": (facts.bot_comment.data or {}).get("cleared_head") if facts.bot_comment else None, "last_unlabel": None if _by_bot(facts.last_unlabel) else facts.last_unlabel, @@ -846,6 +849,11 @@ def render_comment(facts: Facts, decision: Decision, today: date) -> str: f"The bot read the first {MAX_REFERENCES} issues this pull request names, and" f" {facts.references_dropped} more were not read." ) + if facts.claims_dropped: + paragraphs.append( + f"The bot checked your comments on the first {MAX_CLAIM_READS} accepted issues this pull request" + f" names, and {facts.claims_dropped} more were not checked." + ) if _reversed_unlabel(facts): paragraphs.append( "The queue label was removed by someone other than a lead, so the bot set it again." @@ -1234,12 +1242,14 @@ def _assemble( pulls = {int(p["number"]): p for p in listings.open_pulls + listings.merged_pulls} named, dropped = referenced(title, body) refs = [_resolve(n, kind, listings, pulls) for n, kind in named] - claim_reads = 0 + claim_reads = claims_dropped = 0 for index, ref in enumerate(refs): - if claim_reads >= MAX_CLAIM_READS: - break if not _open_accepted(ref) or author in ref.assignees: continue + if claim_reads >= MAX_CLAIM_READS: + # Counted, not read, so the comment can say the list was cut. + claims_dropped += 1 + continue claim_reads += 1 issue_comments = paginate(run, f"{_API}/issues/{ref.number}/comments?per_page=100") refs[index] = replace(ref, author_commented_at=_earliest_by(issue_comments, author)) @@ -1285,6 +1295,7 @@ def _assemble( sweep=sweep, event_action=event_action, rule_landed=landed, + claims_dropped=claims_dropped, ) @@ -1410,11 +1421,11 @@ def _api_write(run: Runner, name: str, args: list[str], stdin: str | None, sleep SWEEP_NAMES = frozenset({"schedule", "workflow_dispatch"}) INTAKE_MODES = frozenset({"evaluate", "dryrun", "retract"}) AUTHOR_SCOPES = frozenset({"all", "bots"}) -QUEUED_ELSEWHERE = "Queued for the reason given on PR #{number}." +QUEUED_ELSEWHERE = "Queued with reason `{reason}`. PR #{number} carries the bot's full comment." RETRACTED_LINE = "Retracted. This pull request is not queued." MISMATCH_ERROR = ( - "::error::PR_INTAKE_TOOL_SHA does not match the armed modules on this ref; dry-sweep and re-arm," - " or unset PR_INTAKE_ENFORCED" + "::error::PR_INTAKE_TOOL_SHA does not match the armed modules on this ref. Dry-sweep and re-arm," + " or unset PR_INTAKE_ENFORCED." ) _QUEUE_REASONS = frozenset(REASONS) - {"retracted"} _DIGITS = re.compile(r"[0-9]{1,9}") @@ -1658,7 +1669,12 @@ def _newly_queued(facts: Facts, decision: Decision) -> bool: def _batch(assessed: list[_Assessment]) -> None: - """Give an author with many new queues one full comment, on the newest, and a pointer on the rest.""" + """Give an author with many new queues one full comment, on the newest, and a pointer on the rest. + + The pointer names its own first reason, since the newest may be queued for another, and + keeps the auto-merge line and the merger's keyword warning, which are about this pull + request alone. + """ by_author: dict[str, list[_Assessment]] = {} for item in assessed: if item.decision is not None and item.decision.comment is not None and _newly_queued(item.facts, item.decision): @@ -1670,8 +1686,14 @@ def _batch(assessed: list[_Assessment]) -> None: for item in group: if item.number != newest: line = item.decision.comment.split("\n", 1)[0] - text = f"{line}\n{MARKER}\n\n{QUEUED_ELSEWHERE.format(number=newest)}" - item.decision = replace(item.decision, comment=text) + paragraphs = [QUEUED_ELSEWHERE.format(reason=item.decision.reasons[0], number=newest)] + if item.decision.disable_auto_merge: + paragraphs.append(_auto_merge_line(item.facts)) + closes = _roadmap_closes(item.facts) + if closes: + paragraphs.append(_keyword_warning(closes)) + text = f"{line}\n{MARKER}\n\n" + "\n\n".join(paragraphs) + item.decision = replace(item.decision, comment=text[:MAX_COMMENT]) def _count(summary: Summary, facts: Facts, decision: Decision) -> None: @@ -1839,7 +1861,8 @@ def retract( except _PER_PULL_ERRORS as exc: _error(summary, number, exc, log) continue - if done is None: + # An empty list means nothing needed undoing, which is unchanged, not cleared. + if not done: summary.unchanged += 1 summary.queued_total += roadmap_model.NEEDS_TRIAGE in _label_names(pull.get("labels")) continue diff --git a/tools/roadmap_sync.py b/tools/roadmap_sync.py index 96f33245..203c172a 100644 --- a/tools/roadmap_sync.py +++ b/tools/roadmap_sync.py @@ -590,9 +590,10 @@ def _intake_recent(gh, today: date, trusted: frozenset[str]) -> list[list]: trusted_author = author in trusted recent.append([number, trusted_author, route if route in CLEAR_ROUTES else None, merged]) # closed_at is never later than updated_at, so once a page reaches an update older - # than the window, no later page can hold a close inside it. + # than the window, no later page can hold a close inside it. A missing update time + # says nothing about the window, so it is read as inside it and the listing goes on. updated = [_when(pull.get("updated_at")) for pull in pulls if isinstance(pull, dict)] - if len(pulls) < 100 or any(when is None or when < cutoff for when in updated): + if len(pulls) < 100 or any(when is not None and when < cutoff for when in updated): return recent # Reporting a partial count as the full one would mislead the review, so the section degrades. raise SyncError(f"closed pull request listing ran past {CLOSED_PAGE_CAP} pages") @@ -651,7 +652,7 @@ def _render_queued(report: dict) -> list[str]: conclusion, run_id, created = meta day = f" on {created[:10]}" if created else "" lines.append( - f"Last scheduled run: the nightly intake sweep run {int(run_id)} " + f"Last scheduled run: the nightly intake sweep run [{int(run_id)}]({repo_url}/actions/runs/{int(run_id)}) " f"concluded {conclusion or 'with no readable conclusion'}{day}." ) else: From ce201bac7724b2948b8a5092b277023a651b18db Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 22:33:24 -0600 Subject: [PATCH 27/31] Name the root notice files, the lane in the flow, and the fixture hand edits Signed-off-by: rocklambros --- .../3-reference-implementation.yml | 3 +-- CONTRIBUTING.md | 7 ++++-- README.md | 2 +- tests/fixtures/pr_intake/scrub.py | 11 ++++++++++ tests/test_intake_prose_guard.py | 22 +++++++++++++++++-- 5 files changed, 38 insertions(+), 7 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/3-reference-implementation.yml b/.github/ISSUE_TEMPLATE/3-reference-implementation.yml index 88faa566..23d9ce09 100644 --- a/.github/ISSUE_TEMPLATE/3-reference-implementation.yml +++ b/.github/ISSUE_TEMPLATE/3-reference-implementation.yml @@ -8,8 +8,7 @@ body: value: >- This is the work the project needs most. Check the open issues labeled `help wanted` before filing, since the one you want may already be accepted. - Already accepted work. Comment on the issue to claim it before opening the pull - request. + Comment on the issue to claim it before opening the pull request. - type: input id: target attributes: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f7734f8..131103c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,8 +64,11 @@ the last 30 days, clears the queue. If a lead has approved and merged a pull req yours before, your pull requests are not queued. An editorial fix on the documentation lane, a page under `docs/topics/` or `design/` or one of the root notice files (a typo, a link, formatting, no change in meaning), needs no issue: tick the editorial box in the -pull request template, and keep every changed file on that lane. Otherwise the pull -request needs an accepted issue that a lead assigned to you. Comment on the issue to claim +pull request template, and keep every changed file on that lane. The root notice files are +`README.md`, `CONTRIBUTING.md`, `CONTRIBUTORS.md`, `CODE_OF_CONDUCT.md`, `GOVERNANCE.md`, +`SECURITY.md`, `STYLE.md`, `LICENSING.md`, and `NOTICE`, and `docs/README.md` counts with +them. `DOCS_LANE_FILES` in `tools/base_branch_guard.py` is the list the bot reads. Otherwise +the pull request needs an accepted issue that a lead assigned to you. Comment on the issue to claim it before opening the pull request. The comment is the claim, and a lead assigns you. An issue labeled `help wanted` is open to anyone who commented on it before opening the pull request, two open pull requests per person per issue. A pull request that meets none of diff --git a/README.md b/README.md index 57501fb3..09891aae 100644 --- a/README.md +++ b/README.md @@ -360,7 +360,7 @@ flowchart LR T -->|"accepted"| A["status:accepted
enters the backlog"] A --> C["Claim
comment on the issue"] C --> P["Pull request
references the issue"] - E["Editorial fix
typo, broken link"] --> P2["Pull request
needs no issue"] + E["Editorial fix
documentation lane
typo, broken link"] --> P2["Pull request
needs no issue"] ``` `CONTRIBUTING.md` is the authority on the full scope list, the branch names, the label diff --git a/tests/fixtures/pr_intake/scrub.py b/tests/fixtures/pr_intake/scrub.py index 71c06946..fa3a2ed6 100644 --- a/tests/fixtures/pr_intake/scrub.py +++ b/tests/fixtures/pr_intake/scrub.py @@ -17,6 +17,17 @@ SHAs, and timestamps. A label's `name` is kept because the gate reads label names. Any string that is a URL becomes a fixed API URL, and any string that still contains a recorded login is replaced whole. The run fails if an email or a non-API URL remains. + +The committed fixtures are this script's output plus hand edits made afterwards, so a +re-run reproduces the scrub and not the edits. Re-apply them after any re-recording: +in reviews.json, review 900001 has a null `user` (a deleted account) and review 900002 has +a `type: Bot` reviewer. In comments.json, the bot's legacy queue comment keeps its opening +sentence, the bot's keyword warning keeps its `` marker, +and three comments are added: 910001, a contributor copying the marker and the data line, +910002, a contributor copying the legacy opening sentence, and 910003, the bot's own +marked comment. In timeline.json, three `unlabeled` events are added: 920001, the bot +removing the queue label, 920002, a lead removing it, and 920003, a lead removing another +label. """ from __future__ import annotations diff --git a/tests/test_intake_prose_guard.py b/tests/test_intake_prose_guard.py index 74322eb6..3d8d05e5 100644 --- a/tests/test_intake_prose_guard.py +++ b/tests/test_intake_prose_guard.py @@ -12,6 +12,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools")) +import base_branch_guard # noqa: E402 import pr_intake # noqa: E402 from apply_governance import desired_labels # noqa: E402 @@ -41,6 +42,10 @@ def read(relative: str) -> str: return flat((REPO_ROOT / relative).read_text(encoding="utf-8")) +def read_lines(relative: str) -> list[str]: + return (REPO_ROOT / relative).read_text(encoding="utf-8").splitlines() + + def section(heading: str) -> str: """The body of a `## ` section of CONTRIBUTING.md, up to the next `## ` heading.""" raw = (REPO_ROOT / "CONTRIBUTING.md").read_text(encoding="utf-8") @@ -59,8 +64,6 @@ def test_claim_phrase_is_in_contributing_readme_template_form_and_seed(): assert CLAIM in read(relative), relative seed = next(label for label in desired_labels() if label.name == pr_intake.HELP_WANTED) assert CLAIM in seed.description - # The form carries the label's own description, word for word. - assert seed.description in read(".github/ISSUE_TEMPLATE/3-reference-implementation.yml") def test_contributing_has_the_precedence_sentence(): @@ -76,6 +79,21 @@ def test_editorial_sentence_names_the_whole_documentation_lane(): assert "the root notice files" in sentence +def test_contributing_names_every_root_notice_file_the_lane_admits(): + # The editorial sentence says "the root notice files". This sentence says which, from + # the same list the gate reads, so the two cannot drift. + body = section("How work gets accepted") + sentence = next(part for part in body.split(". ") if part.startswith("The root notice files are")) + named = set(re.findall(r"`([^`]{1,64})`", sentence)) + assert set(base_branch_guard.DOCS_LANE_FILES) <= named, set(base_branch_guard.DOCS_LANE_FILES) - named + assert "`DOCS_LANE_FILES`" in sentence or "DOCS_LANE_FILES" in body + + +def test_readme_flow_names_the_lane_on_the_editorial_node(): + node = next(line for line in read_lines("README.md") if line.strip().startswith('E["Editorial fix')) + assert "documentation lane" in node + + def test_block_numbers_match_the_gate_constants(): body = section("How work gets accepted") assert f"within the last {pr_intake.FRESH_REVIEW_DAYS} days" in body From 3220bb4080f133e338cd6eb8a86f8412e6c1517a Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 22:35:06 -0600 Subject: [PATCH 28/31] Record the whole-branch review fixes as amendments and correct the body to match Signed-off-by: rocklambros --- design/2026-10-29-pr-intake-rules-design.md | 129 +++++++++++++++----- 1 file changed, 99 insertions(+), 30 deletions(-) diff --git a/design/2026-10-29-pr-intake-rules-design.md b/design/2026-10-29-pr-intake-rules-design.md index 78999417..8a9b7995 100644 --- a/design/2026-10-29-pr-intake-rules-design.md +++ b/design/2026-10-29-pr-intake-rules-design.md @@ -32,10 +32,11 @@ that from facts, in this order, and passes on the first that holds. evaluated as a non-roster author. 1. The author is on the trusted roster (`roadmap_model.trusted_logins`: project leads, workstream leads, triage volunteers, CODEOWNERS logins). -2. A lead has engaged this pull request. Any one of: an `APPROVED` review that is not - dismissed, by a roster login whose `author_association` is `OWNER`, `MEMBER`, or - `COLLABORATOR`, a `CHANGES_REQUESTED` review by such a login within the last 30 days - (after 30 days of silence the pull request is "sent back, no response", a state the +2. A lead has engaged this pull request. A lead is a roster login whose + `author_association` is `OWNER`, `MEMBER`, or `COLLABORATOR`, and a lead's verdict is + their latest `APPROVED` or `CHANGES_REQUESTED` review that is not dismissed. Any one of: + a lead's verdict is `APPROVED`, a lead's verdict is `CHANGES_REQUESTED` within the last + 30 days (after 30 days of silence the pull request is "sent back, no response", a state the health issue lists), or the queue label was removed by a roster login and the pull request's head has not moved since (a hand clear, recorded on the data line). `COMMENTED` and `DISMISSED` reviews do not count: a thread reply is not a request for the work. @@ -72,9 +73,9 @@ is one the bot can undo. The comment. One per pull request. The bot finds it by two facts, never by text alone: the comment's `user.login` equals `roadmap_model.BOT_LOGIN` exactly, and the body carries the marker ``. A legacy queue comment (a bot-authored comment beginning -"Thanks for this. It is queued rather than ignored.", or carrying ``. The comment reports every unmet route, in gate order, in the contributor's terms: @@ -97,7 +98,8 @@ The comment reports every unmet route, in gate order, in the contributor's terms Every comment carries "This pull request is queued rather than ignored. Nothing here is rejected." Where a closing keyword names an open milestoned issue, the merger's warning that exists today is in the same comment. On a pull request opened before the rule landed, -the comment says the rule is new and that the 30-day clock starts today. +the day `PR_INTAKE_LANDED` names, the comment says the rule is new and that the 30-day +clock starts today. Paths in the comment are wrapped in code spans (which suppress autolinks), truncated at 200 characters, at most twenty of them, and the whole comment is capped at 8,000 @@ -120,7 +122,8 @@ the fallback and the rehearsal records which path works. ## Where the gate runs Triggers: `pull_request_target` on `opened`, `edited` (skipped only when none of body, -title, or base changed), `reopened`, `ready_for_review`, `unlabeled`, `auto_merge_enabled`, +title, or base changed), `reopened`, `ready_for_review`, `unlabeled` (skipped unless the +label removed is `status:needs-triage`), `auto_merge_enabled`, and `synchronize` (on which the tool re-evaluates at once only when the editorial box is ticked. A push also lapses a hand clear, which the nightly sweep picks up). `pull_request_review` on `submitted` and `dismissed`. It also runs on `schedule` at 03:10 @@ -128,8 +131,8 @@ UTC, before the 04:10 roadmap sweep so the health issue reports the same night's on `workflow_dispatch` with inputs `pr_number` (optional), `mode` (`evaluate`, the default, `dryrun`, or `retract`), and `authors` (`all`, the default, or `bots`). -Checkout. The job checks out the pull request's base ref on pull request events and the -default branch on schedule and dispatch, with `persist-credentials: false`. The job's `if` +Checkout. The job checks out the pull request's base ref on pull request events and +`integration` on schedule and dispatch, with `persist-credentials: false`. The job's `if` admits pull request events only against `main`, `integration`, or `release/*`, the ruleset-protected bases. The pull request head never reaches the checkout. Version 2 checked out `main` and the @@ -141,11 +144,16 @@ Modes. Two repository variables arm enforcement: `PR_INTAKE_ENFORCED=true` and `PR_INTAKE_TOOL_SHA` equal to the tool SHA, a digest over the git blob SHAs of the five modules the gate runs, at the checked-out ref. When either is missing or mismatched the tool runs in legacy mode: on event runs only, it queues a human author's pull request -that references no accepted issue, carries no editorial tick, and has no bot comment yet, -which are today's exits, and it prints what the gate would do for everything else. A -legacy sweep writes nothing. Every tool change produces a new SHA, so a changed tool runs +that references no accepted issue, carries no editorial tick, and has no bot queue comment +yet (a keyword warning alone is not one), which are today's exits. On the same runs it +posts today's keyword warning as a comment of its own, with today's marker, when a closing +keyword names an open milestoned issue and no bot comment already carries that marker. It +prints what the gate would do for everything else. A legacy sweep writes nothing. Every tool change produces a new SHA, so a changed tool runs in legacy mode until a lead dry-runs it and re-arms, and a sweep that finds enforcement -requested but the SHA mismatched is red. +requested but the SHA mismatched is red. A third variable, `PR_INTAKE_LANDED`, holds the +day enforcement started. It changes one sentence of the comment, arms nothing, and is not +in the facts hash. Unset, no pull request counts as opened before the rule. Malformed, the +same, with one warning. Retract. `mode=retract` removes the queue label from every open pull request whose bot comment carries the marker (or, with `authors=bots`, from every open pull request by @@ -180,8 +188,9 @@ inputs.pr_number) || 'pr-intake-sweep' }}`, `cancel-in-progress` only on pull re events. Batching. On any sweep, an author with more than three newly queued pull -requests gets the full comment on the newest and a one-line comment on the rest ("Queued -for the reason given on PR #n."), each still carrying its data line. +requests gets the full comment on the newest and a short comment on the rest ("Queued +with reason ``. PR #n carries the bot's full comment.", then the auto-merge line and +the merger's warning where they apply), each still carrying its data line. ## Where the logic lives @@ -239,7 +248,8 @@ Under it: "Claims waiting on a lead" (an open accepted issue referenced by a que request on which that author has commented, with the claim's age), "Queued more than 30 days" (the clock starts at the first sweep for pull requests opened before the rule), "Awaiting re-review", "Sent back, no response" (changes requested, 30 days silent), and -"Labeled without a gate record", and three counts: median age of queued pull requests, +"Labeled without a gate record", and three counts: median age of queued pull requests +(every day count here runs from the data line's `since`, the first queue), pull requests a lead cleared by hand that merged in the last 60 days, and non-roster pull requests merged in the last 60 days against the 60 days before. A project lead records the baselines in the `PR_INTAKE_BASELINES` repository variable the morning after @@ -329,8 +339,8 @@ here. A GOVERNANCE/CODEOWNERS follow-up reconciles the roster with repository ac one queues. Closed accepted reference gets its own reason. Several unheld references render as a list. A body failing two routes renders both. Drafts and bots produce no action. Truncated files listing queues. Legacy comment adopted (one edit, no post). - Unchanged facts produce zero writes. Legacy mode writes only today's queue on an event - run, and a legacy sweep writes nothing. A tool SHA mismatch means legacy mode. Retract removes and edits. Auto-merge + Unchanged facts produce zero writes. Legacy mode writes only today's queue and keyword + warning on an event run, and a legacy sweep writes nothing. A tool SHA mismatch means legacy mode. Retract removes and edits. Auto-merge disabled on queue and on the state case. A null review user and a `Bot` reviewer are skipped. A reference to a pull request number is not an issue. A changelog anchor is dropped. @@ -364,7 +374,9 @@ here. A GOVERNANCE/CODEOWNERS follow-up reconciles the roster with repository ac project lead writes on issue #362 why the three roster entries without repository access stay. 3. Dispatch `mode=dryrun` with the variables unset and read the would-queue list and the - tool SHA it prints. + tool SHA it prints. Before promotion, select the `integration` ref in the dispatch form, + because the default branch still carries the old workflow file, which has no `mode` + input. 4. Leads clear the collateral by the gate's own facts, using that list: approve, request changes afresh, or assign, on every pull request a lead has already engaged (PR #24, PR #112, PR #113, PR #297, PR #253, PR #255, and PR #259) and on any first-time @@ -375,8 +387,9 @@ here. A GOVERNANCE/CODEOWNERS follow-up reconciles the roster with repository ac 6. Promote, so pull requests against `main` run the same workflow and the health issue and the monitor report on it. 7. Edit the live `help wanted` label description, retract the queue label from the bots' - pull requests, then set `PR_INTAKE_TOOL_SHA` and `PR_INTAKE_ENFORCED=true` and dispatch - the live sweep outside the 03:00 to 04:30 UTC window. + pull requests, then set `PR_INTAKE_TOOL_SHA`, `PR_INTAKE_LANDED` (today's date), and + `PR_INTAKE_ENFORCED=true` in one sitting and dispatch the live sweep outside the 03:00 + to 04:30 UTC window. 8. The next morning, record `PR_INTAKE_BASELINES` from the health issue. 9. A project lead closes issue #362 by hand. @@ -410,8 +423,9 @@ step: unset `PR_INTAKE_ENFORCED`, then dispatch `mode=retract`. These amend version 3.0. The first group comes from the plan premortem (`2026-10-29-pr-intake-rules-premortem.md`, "Plan premortem"). The second group records -rulings made during implementation. Where a body sentence above contradicted one of them, -the sentence was changed to match. +rulings made during implementation. The third records the fixes from the whole-branch +review. Where a body sentence above contradicted one of them, the sentence was changed to +match. ### From the plan premortem @@ -565,6 +579,57 @@ narrower rule. The roster sentence names triage volunteers with the leads and co The key phrase is "assigned to you". The CODEOWNERS exemption sentence spans two comment lines to stay inside the header's width. +Draft until the owner's line. The implementing pull request opens as a draft and is marked +ready for review once the owner writes the line in "Where the logic lives". This replaces +"before the implementing pull request opens" in that paragraph, which is left as the owner +wrote it. The live `help wanted` label description changes at rollout, not at merge, +because the label is repository state the merge does not touch. + +### From the whole-branch review + +Legacy keyword warning. Legacy mode posts today's keyword warning on event runs, as a +comment of its own carrying today's exact marker, `` +with the numbers in today's string order, when no bot comment already carries that marker. +Legacy's "already commented" test counts a bot comment that opens with today's queue +sentence or carries the intake marker, and not one that carries only the keyword marker. +Adoption prefers the queue comment over the warning. The earlier legacy sentence named +the queue write alone, so between merge and arming a non-roster pull request closing a +roadmap issue got no merger warning, and a pull request carrying only the warning was +never queued. The markers the bot posted are collected as a bot-owned fact outside the +hash. + +Queue clock. Every day count in the health section, the rows, the 30-day heading, and the +median, runs from the data line's `since`. The section earlier computed them from the pull +request's creation time, which contradicted the 30-day clock that starts at the first sweep +for a pull request opened before the rule, and the bot's own comment that says so. + +Sweep ref. Schedule and dispatch check out `integration`, not the default branch. The dry +run that produces `PR_INTAKE_TOOL_SHA` and the sweep that enforces it must run the same +ref. The earlier text and the workflow comment said the default branch. A dispatch before +promotion selects `integration` in the form, since the default branch's workflow file is +the old one. + +Landing date. `PR_INTAKE_LANDED`, a repository variable set with the SHA and the switch, +holds the day enforcement started, and the "rule is new" sentence reads it. Unset or +malformed means no pull request predates the rule, and malformed also prints one warning. +It is outside the hash and outside arming. The earlier code held a placeholder constant no +step could set. + +Latest verdict. A lead's latest `APPROVED` or `CHANGES_REQUESTED` review stands and their +earlier ones drop, so an approval followed by the same lead's request for changes reads as +the request and goes stale after 30 days. A `COMMENTED` review neither grants nor takes back +a verdict, and still counts as the lead looking again for the sent-back test. The earlier +sentence, "an `APPROVED` review that is not dismissed", let such an approval pass forever. + +Review expiry. Only a fresh changes request sets a review pass's expiry. An approval never +goes stale, so an approval-only pass carries none and the nightly short circuit holds on +it. The earlier expiry included approvals and re-read those pull requests every night. + +Fork review runs. A `pull_request_review` run on a pull request whose head repository is +not this one decides with writes off, prints the decision and a notice that the nightly +sweep applies it, and exits 0. The earlier run tried the writes on the read-only token, +and a failed auto-merge disable turned it red. + Shape checks in Risks. The Risks bullet on a changed `gh` shape names the checks the tool has: a failed shared listing, a failed per-pull read, or an unreadable response is red on the nightly sweep, a comments listing with no entry in the REST shape raises rather than @@ -572,8 +637,12 @@ reading as no bot comment, and `paths_truncated` catches a short files listing. text named a weekly legacy-mode check for facts read empty, which no code performs, and the sweep is nightly. -Draft until the owner's line. The implementing pull request opens as a draft and is marked -ready for review once the owner writes the line in "Where the logic lives". This replaces -"before the implementing pull request opens" in that paragraph, which is left as the owner -wrote it. The live `help wanted` label description changes at rollout, not at merge, -because the label is repository state the merge does not touch. +Queue label trigger. The `unlabeled` trigger runs the job only when the label removed is +`status:needs-triage`, the one removal the gate reads. Every other removal spent about +twenty calls to decide nothing new. The concurrency group is unchanged, so a run for another +label's removal still joins the pull request's group and can cancel an event run in +flight before its own job is skipped. The nightly sweep makes the cancelled run's writes. + +Batched comment. The short comment on an author's other new queues names its own first +reason code and keeps the auto-merge line and the merger's keyword warning. The earlier +one-liner dropped both and assumed the newest pull request was queued for the same reason. From 048f7bc50914eb53198c92fd908cbf854aa4a0b2 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 29 Oct 2026 22:41:59 -0600 Subject: [PATCH 29/31] Carry today's keyword marker inside the intake comment's warning so a later legacy run does not post it twice Signed-off-by: rocklambros --- tests/test_intake_prose_guard.py | 2 +- tests/test_pr_intake.py | 12 +++++++++++- tools/pr_intake.py | 11 ++++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/tests/test_intake_prose_guard.py b/tests/test_intake_prose_guard.py index 3d8d05e5..03344343 100644 --- a/tests/test_intake_prose_guard.py +++ b/tests/test_intake_prose_guard.py @@ -85,7 +85,7 @@ def test_contributing_names_every_root_notice_file_the_lane_admits(): body = section("How work gets accepted") sentence = next(part for part in body.split(". ") if part.startswith("The root notice files are")) named = set(re.findall(r"`([^`]{1,64})`", sentence)) - assert set(base_branch_guard.DOCS_LANE_FILES) <= named, set(base_branch_guard.DOCS_LANE_FILES) - named + assert set(base_branch_guard.DOCS_LANE_FILES) == named, set(base_branch_guard.DOCS_LANE_FILES) ^ named assert "`DOCS_LANE_FILES`" in sentence or "DOCS_LANE_FILES" in body diff --git a/tests/test_pr_intake.py b/tests/test_pr_intake.py index 178e9506..4d75129a 100644 --- a/tests/test_pr_intake.py +++ b/tests/test_pr_intake.py @@ -560,6 +560,16 @@ def test_pre_rule_pull_request_comment_says_the_rule_is_new(): assert "The rule is new" not in decide(facts(created_at=datetime(2026, 10, 1, tzinfo=timezone.utc)), TODAY).comment +def test_enforced_warning_carries_the_marker_legacy_mode_reads(): + # An intake comment that carries the warning also carries today's marker, so a later + # legacy run (enforcement switched off without a retract) finds it in keyword_markers + # and does not post the warning a second time as its own comment. + text = pr_intake._keyword_warning((60, 7)) + assert text.startswith("\n") + assert pr_intake._KEYWORD_MARKER.findall(text) == [""] + assert pr_intake._keyword_marker((60, 7)) == pr_intake._keyword_marker((7, 60)) + + def test_keyword_warning_present_when_a_closing_keyword_names_a_milestoned_issue(): roadmap = issue(60, kind="close", labels=(), milestoned=True) decision = decide(facts(references=(roadmap,)), TODAY) @@ -2006,7 +2016,7 @@ def test_any_sweep_batches_an_author_with_many_queues(): short = "Queued with reason `no_accepted`. PR #905 carries the bot's full comment." extra = { 902: ["", "Auto-merge is off while this pull request is queued."], - 903: ["", pr_intake._keyword_warning((60,))], + 903: ["", *pr_intake._keyword_warning((60,)).split("\n")], } for number in range(901, 905): (body,) = fake.bodies(number) diff --git a/tools/pr_intake.py b/tools/pr_intake.py index 3f0be53c..2206e677 100644 --- a/tools/pr_intake.py +++ b/tools/pr_intake.py @@ -624,7 +624,7 @@ def _legacy_warning(facts: Facts) -> str | None: if facts.sweep or not closes: return None names = sorted(str(number) for number in closes) - marker = f"{LEGACY_KEYWORD_MARKER}:{','.join(names)} -->" + marker = _keyword_marker(closes) if marker in facts.keyword_markers: return None return ( @@ -815,10 +815,19 @@ def _auto_merge_line(facts: Facts) -> str: return "Auto-merge is off while this pull request is queued." +def _keyword_marker(closes: tuple[int, ...]) -> str: + """Today's workflow's marker for a set of roadmap closes, numbers in `sort -u` (string) order.""" + return f"{LEGACY_KEYWORD_MARKER}:{','.join(sorted(str(number) for number in closes))} -->" + + def _keyword_warning(closes: tuple[int, ...]) -> str: + # The marker rides inside the intake comment so that `keyword_markers` sees the warning + # as posted. Without it, turning enforcement off without the retract step would let + # legacy mode post the same warning again as a standalone comment. verb = "is" if len(closes) == 1 else "are" issue_word = "that issue" if len(closes) == 1 else "those issues" return ( + f"{_keyword_marker(closes)}\n" f"This pull request's description says it closes {_numbers(closes)}, which {verb} on the project" f" roadmap. When it merges, GitHub closes {issue_word} and the roadmap counts it as delivered." " Please confirm the change delivers the whole issue before merging." From 8b5eaa80f507a32e54effc8bf3b5eaf178746abd Mon Sep 17 00:00:00 2001 From: rocklambros Date: Fri, 30 Oct 2026 19:21:32 -0600 Subject: [PATCH 30/31] Remove a contributor's name from the intake premortem Signed-off-by: rocklambros --- design/2026-10-29-pr-intake-rules-premortem.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design/2026-10-29-pr-intake-rules-premortem.md b/design/2026-10-29-pr-intake-rules-premortem.md index 92e5216d..fa045051 100644 --- a/design/2026-10-29-pr-intake-rules-premortem.md +++ b/design/2026-10-29-pr-intake-rules-premortem.md @@ -99,7 +99,7 @@ Findings, surviving at Plausible or above. 12. The design document names a contributor by full name as the problem case, in a directory that publishes to `main`, with a count that is wrong on the day of writing (14 of 15 declare a closing keyword on a milestoned issue. None of the 15 has received a human review). - Impact: Medium. Reputational, in a public governance record, reversible only by a history rewrite the project denies on `main`. - Confidence: Likely (fires if committed as written). - - Evidence anchor: design v1.1 "Raghuvar Karthik Durga's fifteen open pull requests". + - Evidence anchor: design v1.1 named one contributor beside the phrase "fifteen open pull requests". - Failure mode: the contributor finds the sentence. Any later conduct question starts from it. - Blind spot: design documents are written as memos and published as record. From 0ba6ab90ef4bad0aa23c0ce897c2addf907fcdb3 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Fri, 30 Oct 2026 22:38:58 -0600 Subject: [PATCH 31/31] Record the owner's acceptance of the intake deviation Signed-off-by: rocklambros --- design/2026-10-29-pr-intake-rules-design.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/design/2026-10-29-pr-intake-rules-design.md b/design/2026-10-29-pr-intake-rules-design.md index 8a9b7995..ff902604 100644 --- a/design/2026-10-29-pr-intake-rules-design.md +++ b/design/2026-10-29-pr-intake-rules-design.md @@ -238,6 +238,8 @@ premortem found the owner's own PR #357 would have failed the red check. It is a from the owner's 2026-10-29 request. The owner writes one line here, "Deviation accepted" or "Overridden", before the implementing pull request opens. +Deviation accepted, 2026-10-30, Rock Lambros. + ## The health issue `roadmap_sync.py` gains a section, "Queued pull requests", rendered from the bot's own data