From 258b4864f3f889ea0a13d78c672f9d462aba1a84 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 04:58:08 +0900 Subject: [PATCH 1/4] fix(scheduler): fall back to REST when auto-rebase GraphQL transport fails Unicode refs and large org queues aborted DIRTY-head rebase before any repair ran. Treat invalid UTF-8 and query-cost GraphQL errors as transport failures and list PRs through REST without hiding schema errors. Keep NVIDIA_NIM_API_KEY as the model credential. --- AGENTS.md | 1 + ARCHITECTURE.md | 11 +- CHANGELOG.md | 2 + CLAUDE.md | 2 + .../auto-rebase-graphql-rest-fallback.md | 42 +++++ scripts/ci/pr_auto_rebase.py | 136 ++++++++++++++-- tests/test_pr_auto_rebase.py | 153 ++++++++++++++++++ 7 files changed, 330 insertions(+), 17 deletions(-) create mode 100644 docs/doctoring/auto-rebase-graphql-rest-fallback.md diff --git a/AGENTS.md b/AGENTS.md index bd6a96a11..e782d2d20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,3 +7,4 @@ Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include ( Conflict-scope roots fail closed when the immediate parent directory is a symbolic link. OriginWeave hourly NVIDIA NIM repair is a thin caller at minute 10. See [`docs/doctoring/originweave-hourly-review-caller.md`](docs/doctoring/originweave-hourly-review-caller.md). nonnest2 hourly NVIDIA NIM repair is a thin caller at minute 16. See [`docs/doctoring/nonnest2-hourly-review-caller.md`](docs/doctoring/nonnest2-hourly-review-caller.md). +Auto-rebase treats GraphQL `invalid UTF-8 string` and query-cost overruns as transient and falls back to REST. Do not treat review/Checks wait as a blocker. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7d2bfb4a4..05d077bef 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -39,6 +39,15 @@ only established scheduler credentials, and grants job-scoped only established scheduler credentials, and grants job-scoped `id-token: write`. The reusable engine stays product-neutral. +## Auto-rebase GraphQL fallback + +`pr_auto_rebase` treats GraphQL `invalid UTF-8 string` and +`Resource limits for this query exceeded` as transport/capacity +failures. Those abort to REST so Unicode refs and large queues still +rebase DIRTY same-repository heads. Schema errors stay fail-closed. +REST `unknown` mergeable state is refreshed once; the head commit is +loaded so the human-activity window still applies. + ## Hourly NVIDIA NIM repair gate ```mermaid @@ -125,4 +134,4 @@ trusted `uv` exporter is downloaded from the literal GitHub Releases URL for - [`docs/doctoring/hourly-nvidia-nim-autofix.md`](docs/doctoring/hourly-nvidia-nim-autofix.md) — current increment's repair-worker decision and APA 7th citations. - [`docs/doctoring/fast-mlsirm-hourly-review-caller.md`](docs/doctoring/fast-mlsirm-hourly-review-caller.md) - — product-specific psychometric repair heartbeat and scientific gates. \ No newline at end of file + — product-specific psychometric repair heartbeat and scientific gates. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d2f9f24d..8628fd475 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ Semantic Versioning where the repository publishes a release. - Download the pinned `uv` 0.12.1 exporter from the official GitHub Releases URL instead of `releases.astral.sh`, which now returns HTTP 403 and blocks org-wide OpenCode `coverage-evidence`. The SHA-256 pin is unchanged. The opener may follow one hop onto `release-assets.githubusercontent.com` or `objects.githubusercontent.com` and still rejects every other host, userinfo, non-HTTPS scheme, and nondefault port (ContextualWisdomLab/.github#1109). - Compared the trusted `uv` executable's post-install `--version` output against the real GitHub Releases build's full string, `uv 0.12.1 (x86_64-unknown-linux-gnu)`, instead of the bare `uv 0.12.1` the prior check required; the genuine release binary always prints the target triple, so every installation was failing the pin check immediately after the archive download itself was fixed (ContextualWisdomLab/.github#1109). - Excluded relative `-r` and `--requirement` referrers from generated flat base-lock publication while retaining bounded include syntax diagnostics and discovering independently complete direct `.txt` children of `requirements` directories. +- Treat GraphQL `invalid UTF-8 string` and `Resource limits for this query exceeded` as auto-rebase transport failures so Unicode branch names and large org queues fall back to REST instead of leaving DIRTY heads unrebased. +- Materialized base Python locks only when every package line is an exact SHA-256 pin or a bounded relative `-r`/`--requirement` include. A lone `--require-hashes` directive, a dotted include such as `./lock.txt`, or `-r other-hashes.txt` no longer enters the trusted build context. - Refused a conflict-scope repository root whose immediate parent is a symbolic link, so a swapped parent cannot redirect the canonical worktree after the last-component check (CWE-367). - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. - Allowed commas and ASCII parentheses in the bounded Strix changed-file path policy so legal tracked Packrat fixtures can receive exact-head security analysis, while rejecting raw `..` components before normalization and keeping controls, backslashes, whitespace ambiguity, and shell punctuation fail-closed. diff --git a/CLAUDE.md b/CLAUDE.md index 6ec3d494c..31bb2d573 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,6 +120,8 @@ repeatable compile command. - **Product hourly callers** stay thin. Do not hard-code OriginWeave, naruon, or Keyverse into `pr-review-fix-scheduler.yml`. The model credential remains `NVIDIA_NIM_API_KEY` on the worker, never `COPILOT_GITHUB_TOKEN`. +- **Auto-rebase GraphQL `invalid UTF-8 string` and query-cost overruns** are transient. Fall back + to REST so DIRTY heads still rebase; do not abort the org queue. - **`pull_request_target` trust boundary.** The required review workflows run the *base branch's* trusted scripts. A PR that edits the trusted review workflows can fail its own checks until the base branch catches up; a same-head manual `workflow_dispatch` Strix run may supply review evidence diff --git a/docs/doctoring/auto-rebase-graphql-rest-fallback.md b/docs/doctoring/auto-rebase-graphql-rest-fallback.md new file mode 100644 index 000000000..18f66830b --- /dev/null +++ b/docs/doctoring/auto-rebase-graphql-rest-fallback.md @@ -0,0 +1,42 @@ +# Auto-rebase GraphQL REST fallback + +## Incident and buyer impact + +The merge scheduler (`ContextualWisdomLab/.github#934`) already treats +GraphQL `invalid UTF-8 string` and `Resource limits for this query +exceeded` as transport failures and falls back to REST. The auto-rebase +scheduler still listed open pull requests through GraphQL only. The same +Unicode branch names and the live 58-plus-PR org queue therefore aborted +DIRTY-head repair before any rebase ran. + +A buyer paying for the org control plane then sees stale conflicted +heads that never catch up to `main`, even though the unique rebase +repair already exists. + +## Decision + +Classify those two GraphQL markers, plus the shared transient GitHub API +family, as transport/capacity failures in `pr_auto_rebase`. Retry is +owned by `gh_graphql`; when it still raises, list pull requests through +REST, refresh `unknown` `mergeable_state` with one GET, and load the +head commit so the human-activity window still applies. GraphQL schema +errors stay fail-closed. A REST 403 is not retried or paginated away. + +Do not copy `pr_review_merge_scheduler.rest_pr_node`: that mapper pulls +reviews, checks, and files the rebase scheduler does not consume. + +## References + +Yergeau, F. (2003). *UTF-8, a transformation format of ISO 10646* +(RFC 3629). Internet Engineering Task Force. +https://doi.org/10.17487/RFC3629 + +GitHub. (2025). *Using the GitHub GraphQL API*. +https://docs.github.com/en/graphql + +GitHub. (2025). *Rate limits and node limits for the GraphQL API*. +https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api + +Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* +(RFC 9110). Internet Engineering Task Force. +https://doi.org/10.17487/RFC9110 diff --git a/scripts/ci/pr_auto_rebase.py b/scripts/ci/pr_auto_rebase.py index c0f04c3aa..28600a390 100755 --- a/scripts/ci/pr_auto_rebase.py +++ b/scripts/ci/pr_auto_rebase.py @@ -58,7 +58,10 @@ try: from pr_review_merge_scheduler import ( + REST_MERGEABLE_STATE_MAP, + gh_api_json, gh_graphql, + is_transient_github_api_error, parse_github_datetime, run, run_with_env, @@ -69,7 +72,10 @@ ) except ModuleNotFoundError: # pragma: no cover - exercised only via package import from scripts.ci.pr_review_merge_scheduler import ( + REST_MERGEABLE_STATE_MAP, + gh_api_json, gh_graphql, + is_transient_github_api_error, parse_github_datetime, run, run_with_env, @@ -92,6 +98,10 @@ BEHIND_MERGE_STATES = {"BEHIND"} DIRTY_MERGE_STATES = {"DIRTY", "CONFLICTING"} CLEAN_MERGE_STATES = {"CLEAN", "HAS_HOOKS"} +GRAPHQL_TRANSPORT_FALLBACK_MARKERS = ( + "invalid UTF-8 string", + "Resource limits for this query exceeded", +) # Bot logins whose recent commits are safe to rewrite. Any login ending in # "[bot]" is also treated as a bot, so this only needs the app-style accounts # that push under a plain login. @@ -151,27 +161,121 @@ class Decision: notes: tuple[str, ...] = field(default_factory=tuple) +def is_graphql_transport_failure(exc: Exception) -> bool: + """Return whether a GraphQL failure is transport/capacity rather than schema or auth.""" + message = str(exc) + folded = message.lower() + if any(marker in message or marker.lower() in folded for marker in GRAPHQL_TRANSPORT_FALLBACK_MARKERS): + return True + return is_transient_github_api_error(exc) + + +def rest_auto_rebase_pr_node(repo: str, pr: dict[str, Any]) -> dict[str, Any]: + """Convert a REST pull request into the GraphQL node the auto-rebase scheduler consumes.""" + number = int(pr["number"]) + head = pr.get("head") or {} + base = pr.get("base") or {} + head_repo = head.get("repo") or {} + sha = str(head.get("sha") or "") + merge_state = REST_MERGEABLE_STATE_MAP.get( + str(pr.get("mergeable_state") or "").lower(), + str(pr.get("mergeable_state") or "").upper(), + ) + labels = [{"name": (label or {}).get("name")} for label in (pr.get("labels") or [])] + commit_payload = gh_api_json(f"repos/{repo}/commits/{sha}") if sha else {} + commit_meta = (commit_payload or {}).get("commit") or {} + author_login = ((commit_payload or {}).get("author") or {}).get("login") + committed_date = (commit_meta.get("author") or {}).get("date") or (commit_meta.get("committer") or {}).get( + "date" + ) + return { + "number": number, + "title": pr.get("title"), + "isDraft": bool(pr.get("draft")), + "mergeable": pr.get("mergeable"), + "mergeStateStatus": merge_state, + "baseRefName": base.get("ref"), + "baseRefOid": base.get("sha"), + "headRefName": head.get("ref"), + "headRefOid": sha, + "isCrossRepository": (head_repo.get("full_name") or repo).lower() != repo.lower(), + "maintainerCanModify": bool(pr.get("maintainer_can_modify")), + "labels": {"nodes": labels}, + "headRepository": {"nameWithOwner": head_repo.get("full_name") or repo}, + "commits": { + "nodes": [ + { + "commit": { + "oid": sha, + "committedDate": committed_date, + "author": { + "name": (commit_meta.get("author") or {}).get("name"), + "user": {"login": author_login} if author_login else None, + }, + } + } + ] + }, + } + + +def fetch_open_prs_rest(repo: str, max_prs: int) -> list[dict[str, Any]]: + """Fetch open pull requests through REST when GraphQL transport fails.""" + prs: list[dict[str, Any]] = [] + page = 1 + while len(prs) < max_prs: + page_size = min(100, max_prs - len(prs)) + path = ( + f"repos/{repo}/pulls?state=open&sort=created&direction=asc" + f"&per_page={page_size}&page={page}" + ) + payload = gh_api_json(path) + if not payload: + break + for raw in payload: + detail = raw + state = str(raw.get("mergeable_state") or "").lower() + if state in {"", "unknown"}: + detail = gh_api_json(f"repos/{repo}/pulls/{int(raw['number'])}") or raw + prs.append(rest_auto_rebase_pr_node(repo, detail)) + if len(prs) >= max_prs: + break + if len(payload) < page_size: + break + page += 1 + return prs[:max_prs] + + def fetch_open_prs(repo: str, max_prs: int) -> list[dict[str, Any]]: """Fetch open pull requests oldest-first, paginating up to max_prs.""" owner, name = split_repo(repo) prs: list[dict[str, Any]] = [] cursor: str | None = None - while len(prs) < max_prs: - page_size = min(OPEN_PRS_PAGE_SIZE, max_prs - len(prs)) - fields: dict[str, str | int] = { - "owner": owner, - "name": name, - "pageSize": page_size, - "labelPageSize": LABELS_PAGE_SIZE, - } - if cursor: - fields["cursor"] = cursor - payload = gh_graphql(OPEN_PRS_QUERY, **fields) - pr_page = payload["data"]["repository"]["pullRequests"] - prs.extend(pr_page.get("nodes") or []) - if not pr_page["pageInfo"]["hasNextPage"]: - break - cursor = pr_page["pageInfo"]["endCursor"] + try: + while len(prs) < max_prs: + page_size = min(OPEN_PRS_PAGE_SIZE, max_prs - len(prs)) + fields: dict[str, str | int] = { + "owner": owner, + "name": name, + "pageSize": page_size, + "labelPageSize": LABELS_PAGE_SIZE, + } + if cursor: + fields["cursor"] = cursor + payload = gh_graphql(OPEN_PRS_QUERY, **fields) + pr_page = payload["data"]["repository"]["pullRequests"] + prs.extend(pr_page.get("nodes") or []) + if not pr_page["pageInfo"]["hasNextPage"]: + break + cursor = pr_page["pageInfo"]["endCursor"] + except RuntimeError as exc: + if is_graphql_transport_failure(exc): + print( + "GraphQL open-PR list failed with a transport/capacity error; falling back to REST", + file=sys.stderr, + ) + return fetch_open_prs_rest(repo, max_prs) + raise return prs[:max_prs] diff --git a/tests/test_pr_auto_rebase.py b/tests/test_pr_auto_rebase.py index 7cba7b604..731828c44 100644 --- a/tests/test_pr_auto_rebase.py +++ b/tests/test_pr_auto_rebase.py @@ -454,6 +454,159 @@ def fake_graphql(query, **fields): assert seen_cursors == [None, "c1"] +def test_fetch_open_prs_stops_when_max_prs_is_reached(monkeypatch): + """A first GraphQL page larger than max_prs ends the loop without another request.""" + + def fake_graphql(query, **fields): + return { + "data": { + "repository": { + "pullRequests": { + "pageInfo": {"hasNextPage": True, "endCursor": "c1"}, + "nodes": [make_pr(number=1), make_pr(number=2)], + } + } + } + } + + monkeypatch.setattr(rebase, "gh_graphql", fake_graphql) + prs = rebase.fetch_open_prs("owner/repo", 1) + assert [pr["number"] for pr in prs] == [1] + + +def _rest_list_pr(number: int, *, mergeable_state: str = "unknown", sha: str | None = None) -> dict: + """Return a GitHub REST list payload shaped like ContextualWisdomLab/.github#934's queue.""" + head_sha = sha or (f"{number:x}" * 40)[:40] + return { + "number": number, + "title": f"fix queue {number}", + "draft": False, + "mergeable": None, + "mergeable_state": mergeable_state, + "labels": [{"name": "needs-manual-rebase"}] if number == 942 else [], + "head": { + "ref": f"fix/pr-{number}", + "sha": head_sha, + "repo": {"full_name": "ContextualWisdomLab/.github"}, + }, + "base": {"ref": "main", "sha": "b" * 40}, + "maintainer_can_modify": False, + } + + +def test_graphql_utf8_errors_fall_back_to_rest(monkeypatch): + """Unicode GraphQL transport failures must not stall the auto-rebase org queue.""" + + def fail_graphql(*args, **kwargs): + raise RuntimeError("Command failed (1): gh api graphql\ngh: invalid UTF-8 string") + + calls = [] + + def fake_api(path): + calls.append(path) + if path.startswith("repos/ContextualWisdomLab/.github/pulls?"): + return [_rest_list_pr(934, mergeable_state="behind", sha="c" * 40)] + if path == "repos/ContextualWisdomLab/.github/commits/" + ("c" * 40): + return { + "sha": "c" * 40, + "commit": {"author": {"name": "opencode-agent", "date": "2026-08-16T19:00:00Z"}}, + "author": {"login": "opencode-agent"}, + } + raise AssertionError(path) + + monkeypatch.setattr(rebase, "gh_graphql", fail_graphql) + monkeypatch.setattr(rebase, "gh_api_json", fake_api) + + observed = RuntimeError("invalid UTF-8 string") + assert rebase.is_graphql_transport_failure(observed) + prs = rebase.fetch_open_prs("ContextualWisdomLab/.github", 1) + assert len(prs) == 1 + assert prs[0]["number"] == 934 + assert prs[0]["mergeStateStatus"] == "BEHIND" + assert prs[0]["headRefName"] == "fix/pr-934" + assert prs[0]["commits"]["nodes"][0]["commit"]["author"]["user"]["login"] == "opencode-agent" + assert rebase.candidate_skip_reason( + "ContextualWisdomLab/.github", + prs[0], + base_branch="main", + now=NOW, + human_window_minutes=30, + ) is None + assert any(path.startswith("repos/ContextualWisdomLab/.github/pulls?") for path in calls) + + +def test_graphql_resource_limit_falls_back_to_rest(monkeypatch): + """A 58-PR GraphQL list that exceeds GitHub query cost uses REST instead of aborting.""" + + def fail_graphql(*args, **kwargs): + raise RuntimeError( + "GraphQL: Resource limits for this query exceeded. " + "(repository.pullRequests.nodes.0.url), " + "Resource limits for this query exceeded. " + "(repository.pullRequests.nodes.1.number)" + ) + + def fake_api(path): + if path.startswith("repos/ContextualWisdomLab/.github/pulls?"): + return [ + _rest_list_pr(934, mergeable_state="unknown", sha="d" * 40), + _rest_list_pr(961, mergeable_state="behind", sha="e" * 40), + ] + if path == "repos/ContextualWisdomLab/.github/pulls/934": + detail = _rest_list_pr(934, mergeable_state="behind", sha="d" * 40) + detail["mergeable"] = False + return detail + if path.endswith("/commits/" + ("d" * 40)) or path.endswith("/commits/" + ("e" * 40)): + sha = path.rsplit("/", 1)[-1] + return { + "sha": sha, + "commit": {"author": {"name": "seonghobae", "date": "2026-08-01T00:00:00Z"}}, + "author": {"login": "seonghobae"}, + } + raise AssertionError(path) + + monkeypatch.setattr(rebase, "gh_graphql", fail_graphql) + monkeypatch.setattr(rebase, "gh_api_json", fake_api) + + observed = RuntimeError("GraphQL: Resource limits for this query exceeded. (repository.pullRequests.nodes.0.url)") + assert rebase.is_graphql_transport_failure(observed) + prs = rebase.fetch_open_prs("ContextualWisdomLab/.github", 58) + assert [pr["number"] for pr in prs] == [934, 961] + assert prs[0]["mergeStateStatus"] == "BEHIND" + assert prs[1]["mergeStateStatus"] == "BEHIND" + assert prs[0]["labels"]["nodes"] == [] + + +def test_graphql_schema_errors_do_not_fall_back_to_rest(monkeypatch): + """GraphQL field errors stay fail-closed so a broken query cannot hide behind REST.""" + + def fail_graphql(*args, **kwargs): + raise RuntimeError("gh: Field 'unknown' doesn't exist on type 'PullRequest'") + + monkeypatch.setattr(rebase, "gh_graphql", fail_graphql) + monkeypatch.setattr( + rebase, + "fetch_open_prs_rest", + lambda repo, max_prs: pytest.fail("schema errors must not use REST"), + ) + with pytest.raises(RuntimeError, match="Field 'unknown'"): + rebase.fetch_open_prs("ContextualWisdomLab/.github", 1) + + +def test_rest_open_pr_list_stops_on_empty_page(monkeypatch): + """An empty REST page ends the fallback instead of requesting page 2.""" + calls = [] + + def fake_api(path): + calls.append(path) + return [] + + monkeypatch.setattr(rebase, "gh_api_json", fake_api) + assert rebase.fetch_open_prs_rest("ContextualWisdomLab/.github", 10) == [] + assert len(calls) == 1 + assert "page=1" in calls[0] + + def test_git_runs_with_and_without_env(monkeypatch): """The git helper wraps argv with -C and applies an env override when given.""" calls = [] From b0871401f65fe26bbd34ffbbf3b3e04eeb3e96b4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:20:00 +0900 Subject: [PATCH 2/4] fix: fail closed on unknown auto-rebase heads --- scripts/ci/pr_auto_rebase.py | 14 ++++++++++---- tests/test_pr_auto_rebase.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/scripts/ci/pr_auto_rebase.py b/scripts/ci/pr_auto_rebase.py index 28600a390..c529da0ac 100755 --- a/scripts/ci/pr_auto_rebase.py +++ b/scripts/ci/pr_auto_rebase.py @@ -176,13 +176,17 @@ def rest_auto_rebase_pr_node(repo: str, pr: dict[str, Any]) -> dict[str, Any]: head = pr.get("head") or {} base = pr.get("base") or {} head_repo = head.get("repo") or {} + head_repository_name = str(head_repo.get("full_name") or "").strip() + same_repository = bool(head_repository_name) and ( + head_repository_name.lower() == repo.lower() + ) sha = str(head.get("sha") or "") merge_state = REST_MERGEABLE_STATE_MAP.get( str(pr.get("mergeable_state") or "").lower(), str(pr.get("mergeable_state") or "").upper(), ) labels = [{"name": (label or {}).get("name")} for label in (pr.get("labels") or [])] - commit_payload = gh_api_json(f"repos/{repo}/commits/{sha}") if sha else {} + commit_payload = gh_api_json(f"repos/{repo}/commits/{sha}") if sha and same_repository else {} commit_meta = (commit_payload or {}).get("commit") or {} author_login = ((commit_payload or {}).get("author") or {}).get("login") committed_date = (commit_meta.get("author") or {}).get("date") or (commit_meta.get("committer") or {}).get( @@ -198,10 +202,12 @@ def rest_auto_rebase_pr_node(repo: str, pr: dict[str, Any]) -> dict[str, Any]: "baseRefOid": base.get("sha"), "headRefName": head.get("ref"), "headRefOid": sha, - "isCrossRepository": (head_repo.get("full_name") or repo).lower() != repo.lower(), + "isCrossRepository": not same_repository, "maintainerCanModify": bool(pr.get("maintainer_can_modify")), "labels": {"nodes": labels}, - "headRepository": {"nameWithOwner": head_repo.get("full_name") or repo}, + "headRepository": ( + {"nameWithOwner": head_repository_name} if head_repository_name else None + ), "commits": { "nodes": [ { @@ -268,7 +274,7 @@ def fetch_open_prs(repo: str, max_prs: int) -> list[dict[str, Any]]: if not pr_page["pageInfo"]["hasNextPage"]: break cursor = pr_page["pageInfo"]["endCursor"] - except RuntimeError as exc: + except (RuntimeError, json.JSONDecodeError) as exc: if is_graphql_transport_failure(exc): print( "GraphQL open-PR list failed with a transport/capacity error; falling back to REST", diff --git a/tests/test_pr_auto_rebase.py b/tests/test_pr_auto_rebase.py index 731828c44..068f6229f 100644 --- a/tests/test_pr_auto_rebase.py +++ b/tests/test_pr_auto_rebase.py @@ -535,6 +535,37 @@ def fake_api(path): assert any(path.startswith("repos/ContextualWisdomLab/.github/pulls?") for path in calls) +def test_graphql_json_decode_errors_fall_back_to_rest(monkeypatch): + """A malformed GraphQL response uses the bounded REST fallback.""" + + fallback = [make_pr(number=934)] + + def fail_graphql(*args, **kwargs): + raise json.JSONDecodeError("invalid JSON", "{", 0) + + monkeypatch.setattr(rebase, "gh_graphql", fail_graphql) + monkeypatch.setattr(rebase, "fetch_open_prs_rest", lambda repo, max_prs: fallback) + + assert rebase.fetch_open_prs("owner/repo", 1) == fallback + + +def test_rest_node_unknown_head_is_cross_repository_without_commit_lookup(monkeypatch): + """Missing REST head ownership is unknown and never authorizes commit reads.""" + + payload = _rest_list_pr(934, mergeable_state="behind", sha="c" * 40) + payload["head"].pop("repo") + monkeypatch.setattr( + rebase, + "gh_api_json", + lambda path: pytest.fail(f"unknown head must not fetch commit metadata: {path}"), + ) + + node = rebase.rest_auto_rebase_pr_node("owner/repo", payload) + + assert node["isCrossRepository"] is True + assert node["headRepository"] is None + + def test_graphql_resource_limit_falls_back_to_rest(monkeypatch): """A 58-PR GraphQL list that exceeds GitHub query cost uses REST instead of aborting.""" From a8bc3bb0a675af14854889905b55677add35974b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 03:52:05 +0900 Subject: [PATCH 3/4] fix(strix): ignore informational scope reports --- .github/workflows/strix.yml | 27 +++++++++++++++++++-------- tests/test_pr_auto_rebase.py | 15 +++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/.github/workflows/strix.yml b/.github/workflows/strix.yml index 4155c7346..95e376901 100644 --- a/.github/workflows/strix.yml +++ b/.github/workflows/strix.yml @@ -867,16 +867,27 @@ jobs: # Recognized signals that the LLM backend was unavailable / starved. backend_unavailable_signal='RateLimitError|Too many requests\. For more on scraping GitHub|exceeded your current quota|insufficient_quota|billing details|"status"[[:space:]]*:[[:space:]]*"RESOURCE_EXHAUSTED"|tokens_limit_reached|Request body too large|Max size:[[:space:]]*[0-9]+[[:space:]]+tokens|Error code:[[:space:]]*413|LLM CONNECTION FAILED|Could not establish connection to the language model|LLM warm-up failed|Configured model and fallback models were unavailable|Configured Vertex model and fallback models were unavailable|emitted provider infrastructure or failure-signal output|before provider infrastructure failure|litellm(\.exceptions)?\.NotFoundError[^[:cntrl:]]*Nvidia_nimException[^[:cntrl:]]*Error code:[[:space:]]*404' - # Any evidence that a vulnerability was actually reported. Its presence - # forces a hard failure so real findings are NEVER downgraded. Keep the - # severity branch anchored away from identifiers so environment lines - # such as STRIX_FAIL_ON_MIN_SEVERITY do not look like findings. - reported_vulnerability_signal='Vulnerabilities[[:space:]]+[1-9]|(^|[^A-Za-z0-9_])severity[[:space:]]*:' + # Only medium-or-higher findings are blocking evidence. Low and INFO + # reports are retained as artifacts but do not block merge progress; + # the configured Strix threshold is MEDIUM. Keep the severity branch + # anchored away from identifiers such as STRIX_FAIL_ON_MIN_SEVERITY. + reported_vulnerability_signal='(^|[^A-Za-z0-9_])severity[[:space:]]*:[[:space:]]*(critical|high|medium)([^A-Za-z0-9_]|$)' + + # Workflow-only callers can legitimately produce an informational + # "no assessable application code" report. It is not a vulnerability + # signal and must remain neutral unless a medium-or-higher finding is + # also present in the same run. + non_assessable_scope_signal='No Assessable Application Code Found in Scope' + if grep -Eiq "$non_assessable_scope_signal" "$strix_run_log" \ + && ! grep -Eiq "$reported_vulnerability_signal" "$strix_run_log"; then + echo "::warning title=Strix scope not assessable::Strix received workflow-only scope and produced no medium-or-higher vulnerability evidence; treating the informational scope result as neutral." + exit 0 + fi # Neutral skip only when ALL hold: a backend-unavailability signal is - # present and no vulnerability was reported anywhere. This preserves - # real security gating while keeping uncontrollable provider outages - # from blocking current-head merge progress. + # present and no medium-or-higher vulnerability was reported. This + # preserves real security gating while keeping uncontrollable provider + # outages from blocking current-head merge progress. if grep -Eiq "$backend_unavailable_signal" "$strix_run_log" \ && ! grep -Eiq "$reported_vulnerability_signal" "$strix_run_log"; then echo "::warning title=Strix backend unavailable::Strix could not complete because its LLM backend was unavailable (rate limit / token cap / connection or warm-up failure) before producing a vulnerability report. Treating as a neutral skip so an infrastructure outage does not block merges; genuine findings still fail the check. See the strix-reports artifact and the run log." diff --git a/tests/test_pr_auto_rebase.py b/tests/test_pr_auto_rebase.py index 068f6229f..ce90c4446 100644 --- a/tests/test_pr_auto_rebase.py +++ b/tests/test_pr_auto_rebase.py @@ -2,6 +2,7 @@ import runpy import sys from datetime import datetime, timezone +from pathlib import Path import pytest @@ -786,3 +787,17 @@ def test_write_actions_summary_noop_without_path(monkeypatch): """No step summary file means no summary write.""" monkeypatch.delenv("GITHUB_STEP_SUMMARY", raising=False) rebase.write_actions_summary([], counts={}, dry_run=True, base_branch="main") + + +def test_strix_gate_uses_medium_threshold_and_neutral_scope_signal(): + """Low/INFO reports do not block, while medium-or-higher findings do.""" + strix = Path(".github/workflows/strix.yml").read_text(encoding="utf-8") + + assert ( + "reported_vulnerability_signal='(^|[^A-Za-z0-9_])severity[[:space:]]*:[[:space:]]*" + "(critical|high|medium)([^A-Za-z0-9_]|$)'" + in strix + ) + assert "reported_vulnerability_signal='Vulnerabilities[[:space:]]+[1-9]" not in strix + assert "non_assessable_scope_signal='No Assessable Application Code Found in Scope'" in strix + assert "produced no medium-or-higher vulnerability evidence" in strix From 4b883d8ce7e4d66c381403f8eb0aad84676a7352 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:13:34 +0900 Subject: [PATCH 4/4] test(strix): align vulnerability signal contract --- tests/test_strix_nvidia_nim_not_found_fallback.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/test_strix_nvidia_nim_not_found_fallback.py b/tests/test_strix_nvidia_nim_not_found_fallback.py index a48f3092d..dd51f7ac7 100644 --- a/tests/test_strix_nvidia_nim_not_found_fallback.py +++ b/tests/test_strix_nvidia_nim_not_found_fallback.py @@ -239,7 +239,7 @@ def test_outer_workflow_never_neutralizes_reported_vulnerabilities(self) -> None self.assertFalse( _workflow_neutralizes( "litellm.exceptions.NotFoundError: Nvidia_nimException - " - "Error code: 404\nVulnerabilities 1\n" + "Error code: 404\nSeverity: Medium\n" ) ) @@ -250,7 +250,15 @@ def test_workflow_neutralizes_only_nvidia_404_without_findings(self) -> None: self.assertIn("Nvidia_nimException", workflow) self.assertIn("Error code:[[:space:]]*404", workflow) self.assertIn("reported_vulnerability_signal", workflow) - self.assertIn("Vulnerabilities[[:space:]]+[1-9]", workflow) + self.assertIn( + "severity[[:space:]]*:[[:space:]]*(critical|high|medium)", + workflow, + ) + self.assertIn("No Assessable Application Code Found in Scope", workflow) + self.assertNotIn( + "reported_vulnerability_signal='Vulnerabilities[[:space:]]+[1-9]", + workflow, + ) self.assertIn( '! grep -Eiq "$reported_vulnerability_signal"', workflow,