This is an automated email from the ASF dual-hosted git repository.
hello-stephen pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new cb41339ffd2 [fix](ci) Retry transient review GET failures within
bounded budgets (#69510)
cb41339ffd2 is described below
commit cb41339ffd23793e45720e9d0ebf1094cf862913
Author: shuke <[email protected]>
AuthorDate: Mon Nov 2 14:23:14 2026 +0800
[fix](ci) Retry transient review GET failures within bounded budgets
(#69510)
Related PR: #69469
Problem Summary: A single transient GitHub response currently aborts the
review pipeline. [Input resolution for
#69322](https://github.com/apache/doris/actions/runs/39794013905/job/119876748768)
stopped on a GET 503 despite `Retry-After: 2`; [aggregate resolution for
#69438](https://github.com/apache/doris/actions/runs/39760934956/job/119764632659)
stopped on a paginated GET 504 after emitting partial output.
The shared helper now retries recognized body-free REST GETs on
502/503/504, for at most three attempts. Each attempt has a 30-second
limit; attempts and waits share a 90-second budget or the caller's
shorter timeout. It respects Retry-After, records every failed attempt,
discards failed pagination output, and returns only a successful
attempt's complete output. Exhaustion still fails. Writes, GraphQL,
unknown argument forms, other HTTP errors, transport/timeouts and JSON
parsing errors are not retried.
Input resolution can also fail before publishing its PR-number output.
Failure notices now fall back to the authorized trigger's numeric PR
identity, explicitly select the repository, and name the
input-resolution failure in both the notice and terminal error. This
fallback does not permit status publication for an unresolved head/base.
This change can merge independently. It includes the same explicit
`--repo` argument as #69469; a local combination with #69469 and #69504
merged cleanly and passed the macOS suite.
---
.github/scripts/review-github-diagnostics.md | 16 +-
.github/scripts/review_github_api.py | 104 ++++++++--
.github/scripts/test_review_get_retry.py | 297 +++++++++++++++++++++++++++
.github/scripts/test_review_input_failure.py | 146 +++++++++++++
.github/scripts/test_submit_review.py | 4 +-
.github/workflows/code-review-runner.yml | 22 +-
6 files changed, 568 insertions(+), 21 deletions(-)
diff --git a/.github/scripts/review-github-diagnostics.md
b/.github/scripts/review-github-diagnostics.md
index ad20cb934cd..157cc1f082d 100644
--- a/.github/scripts/review-github-diagnostics.md
+++ b/.github/scripts/review-github-diagnostics.md
@@ -8,7 +8,21 @@ submission helper and head watchdog.
Use python3 "$REVIEW_GH_HELPER" api ... in these workflow steps. Python helpers
use run_gh with captured text output, check=True and their existing timeout;
use decode_json when decoding its result. Request arguments, normal stdout,
-write deduplication, permission decisions and retry policy remain unchanged.
+write deduplication and permission decisions remain unchanged.
+
+Body-free REST GETs using the pipeline's known gh api arguments retry HTTP
+502/503/504 at most twice. Each attempt is limited to 30 seconds; all attempts
+and waits share 90 seconds, or the caller's shorter timeout. Retry-After
+(seconds or an HTTP date) is respected; a malformed header or a wait exceeding
+the remaining budget stops retries. Each failed attempt keeps its diagnostic.
+Only a successful attempt's complete stdout is returned; failed pagination
+output is discarded before restarting the full read. Exhaustion still fails.
+
+Writes, GraphQL, fields/input, unknown argument forms, transport/timeouts, JSON
+parse failures and other HTTP statuses retain single-attempt behavior. The
+failure notification can use the authorized event/input PR number when input
+resolution failed before producing outputs; this does not authorize status
+writes for an unresolved head/base.
On a failure, the helper prints a short diagnostic and saves one JSON record
under REVIEW_GITHUB_DIAGNOSTICS_DIR. Records include the command exit status or
diff --git a/.github/scripts/review_github_api.py
b/.github/scripts/review_github_api.py
index fc2a014fa37..50858d76065 100644
--- a/.github/scripts/review_github_api.py
+++ b/.github/scripts/review_github_api.py
@@ -16,7 +16,7 @@
# specific language governing permissions and limitations
# under the License.
-"""Capture gh HTTP failures without changing stdout, exit status, or retry
policy."""
+"""Capture gh HTTP failures and retry transient failures of read-only REST
GETs."""
import json
import os
@@ -26,6 +26,7 @@ import sys
import time
import uuid
from datetime import datetime, timezone
+from email.utils import parsedate_to_datetime
from pathlib import Path
from urllib.parse import urlsplit
@@ -164,24 +165,92 @@ def record_failure(command, error, stdout="", stderr="",
*, diagnostic=None):
return ordinary, record
+def is_read_only_get(command):
+ """Recognize only the body-free gh api forms used by the review pipeline.
+
+ Unknown options remain single-attempt. In particular, fields/input imply
+ POST in gh, and GraphQL uses POST even without an explicit method. Never
+ infer safety from the last HTTP response: a write can redirect to GET.
+ """
+ if len(command) < 3 or Path(command[0]).name != "gh" or command[1] !=
"api":
+ return False
+ endpoint = None
+ args = iter(command[2:])
+ for arg in args:
+ if arg in ("--paginate", "--slurp", "-XGET", "--method=GET"):
+ continue
+ if arg in ("-X", "--method"):
+ if next(args, None) != "GET":
+ return False
+ elif arg in ("-H", "--header"):
+ if next(args, None) is None:
+ return False
+ elif arg.startswith("-") or endpoint is not None:
+ return False
+ else:
+ endpoint = arg
+ return endpoint is not None and
urlsplit(endpoint).path.rstrip("/").split("/")[-1] != "graphql"
+
+
+def retry_delay(response, attempt):
+ """Do not shorten a server-requested wait, or guess if it is malformed."""
+ delay = 2 ** attempt
+ value = response["headers"].get("retry-after")
+ if value is None:
+ return delay
+ try:
+ if value.isdigit():
+ return max(delay, int(value))
+ when = parsedate_to_datetime(value)
+ return max(delay, (when - datetime.now(timezone.utc)).total_seconds())
+ except (TypeError, ValueError, OverflowError):
+ return None
+
+
def run_gh(command, **kwargs):
- """subprocess.run contract for captured text gh calls, with failure
evidence."""
+ """Buffer each attempt; a caller timeout bounds the entire GET
operation."""
env = dict(kwargs.pop("env", os.environ))
env.update(GH_DEBUG="api", NO_COLOR="1")
- try:
- result = subprocess.run(command, env=env, **kwargs)
- except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
- ordinary, record = record_failure(command, error, error.stdout,
error.stderr)
- error.stderr = ordinary + "\n" + record["summary"]
- # Preserve the original return code/timeout and stdout for callers.
- raise
- except OSError as error:
- record_failure(command, error)
- raise
- response, ordinary = split_trace(result.stderr)
- result.stderr = ordinary
- result.github_response = response
- return result
+ retry_get = (is_read_only_get(command) and kwargs.get("check") is True
+ and kwargs.get("capture_output") is True and
kwargs.get("text") is True
+ and kwargs.get("input") is None and kwargs.get("stdin") is
None)
+ if retry_get:
+ # Fit within the runner's two-minute input-resolution step. Existing
+ # shorter submission/watchdog budgets include all attempts and sleeps.
+ budget = min(90, kwargs.get("timeout") if kwargs.get("timeout") is not
None else 90)
+ deadline = time.monotonic() + budget
+ for attempt in range(1, 4):
+ options = dict(kwargs)
+ if retry_get:
+ options["timeout"] = min(30, max(0, deadline - time.monotonic()))
+ try:
+ result = subprocess.run(command, env=env, **options)
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as
error:
+ ordinary, record = record_failure(command, error, error.stdout,
error.stderr)
+ error.stderr = ordinary + "\n" + record["summary"]
+ if retry_get:
+ # gh --paginate may emit incomplete JSON before a later page
+ # fails. Retain it only in diagnostics, never in caller output.
+ error.stdout = ""
+ response = record["http"]
+ if (isinstance(error, subprocess.CalledProcessError)
+ and error.returncode > 0 and response["method"] ==
"GET"
+ and response["status"] in (502, 503, 504) and attempt
< 3):
+ delay = retry_delay(response, attempt)
+ if delay is not None and delay < deadline -
time.monotonic():
+ print(f"Retrying GitHub GET after HTTP
{response['status']} in {delay:g}s "
+ f"(attempt {attempt + 1}/3)", file=sys.stderr,
flush=True)
+ time.sleep(delay)
+ if time.monotonic() < deadline:
+ continue
+ raise
+ except OSError as error:
+ record_failure(command, error)
+ raise
+ response, ordinary = split_trace(result.stderr)
+ result.stderr = ordinary
+ result.github_response = response
+ return result
def decode_json(result, command):
@@ -201,6 +270,9 @@ def main():
sys.stdout.write(error.stdout or "")
print(error.stderr, file=sys.stderr)
return error.returncode if error.returncode >= 0 else 128 -
error.returncode
+ except subprocess.TimeoutExpired as error:
+ print(error.stderr, file=sys.stderr)
+ return 124
except OSError as error:
print(f"Could not execute gh ({type(error).__name__})",
file=sys.stderr)
return 1
diff --git a/.github/scripts/test_review_get_retry.py
b/.github/scripts/test_review_get_retry.py
new file mode 100644
index 00000000000..d82c3c73c37
--- /dev/null
+++ b/.github/scripts/test_review_get_retry.py
@@ -0,0 +1,297 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import http.server
+import io
+import json
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+import threading
+import unittest
+from contextlib import redirect_stderr
+from datetime import datetime, timedelta, timezone
+from email.utils import format_datetime
+from pathlib import Path
+from unittest import mock
+from urllib.parse import parse_qs, urlsplit
+
+import review_github_api as api
+
+
+def trace(status, *, method="GET", retry_after=None):
+ header = "" if retry_after is None else f"< Retry-After: {retry_after}\n"
+ return ("* Request at now\n> " + method + " /repos/a/b/pulls HTTP/2.0\n"
+ + f"< HTTP/2.0 {status} Result\n< X-GitHub-Request-Id:
retry-test\n"
+ + header + "\nresponse\n* Request took 1ms\n")
+
+
+class ReadRetryTest(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.enterContext(mock.patch.dict(os.environ,
{"REVIEW_GITHUB_DIAGNOSTICS_DIR": self.tmp.name}))
+ self.enterContext(redirect_stderr(io.StringIO()))
+ self.now = 0
+ self.sleeps = []
+ self.enterContext(mock.patch.object(api.time, "monotonic",
side_effect=lambda: self.now))
+ self.enterContext(mock.patch.object(api.time, "sleep",
side_effect=self.sleep))
+ self.command = ["gh", "api", "repos/a/b/pulls"]
+
+ def sleep(self, seconds):
+ self.sleeps.append(seconds)
+ self.now += seconds
+
+ def failure(self, status=503, *, method="GET", retry_after=None,
stdout="partial"):
+ return subprocess.CalledProcessError(1, self.command, stdout,
+ trace(status, method=method,
retry_after=retry_after))
+
+ def success(self, stdout='[{"id":2}]'):
+ return subprocess.CompletedProcess(self.command, 0, stdout, trace(200))
+
+ def run_get(self, command=None, **kwargs):
+ return api.run_gh(command or self.command, check=True,
capture_output=True, text=True, **kwargs)
+
+ def records(self):
+ return [json.loads(p.read_text()) for p in
Path(self.tmp.name).glob("*.json")]
+
+ def test_known_get_forms_only(self):
+ for args in (["repos/a/b/pulls"], ["--paginate", "--slurp",
"repos/a/b/pulls"],
+ ["repos/a/b/pulls", "-H", "Accept: application/json"],
+ ["-X", "GET", "repos/a/b/pulls"], ["--method=GET",
"repos/a/b/pulls"],
+ ["-XGET", "repos/a/b/pulls"]):
+ with self.subTest(args=args):
+ self.assertTrue(api.is_read_only_get(["gh", "api", *args]))
+ for args in (["graphql"], ["https://api.github.com/graphql"],
+ ["repos/a/b/pulls", "-f", "title=x"], ["repos/a/b/pulls",
"-ftitle=x"],
+ ["repos/a/b/pulls", "--field=title=x"],
["repos/a/b/pulls", "-Ftitle=x"],
+ ["repos/a/b/pulls", "--input", "-"], ["repos/a/b/pulls",
"--input=-"],
+ ["repos/a/b/pulls", "-X", "POST"], ["repos/a/b/pulls",
"-XPATCH"],
+ ["repos/a/b/pulls", "--method=DELETE"],
+ ["repos/a/b/pulls", "-XGET", "-XPOST"],
+ ["repos/a/b/pulls", "--unknown"], ["repos/a/b/pulls",
"-H"], []):
+ with self.subTest(args=args):
+ self.assertFalse(api.is_read_only_get(["gh", "api", *args]))
+ self.assertFalse(api.is_read_only_get(["gh", "pr", "comment", "1"]))
+
+ def test_transient_statuses_retry_and_return_only_complete_success(self):
+ for status in (502, 503, 504):
+ with self.subTest(status=status):
+ with mock.patch.object(api.subprocess, "run",
side_effect=[self.failure(status), self.success()]) as run:
+ result = self.run_get()
+ self.assertEqual(result.stdout, '[{"id":2}]')
+ self.assertEqual(run.call_count, 2)
+ self.assertEqual(result.stderr, "")
+ self.assertEqual(self.sleeps, [2, 2, 2])
+ self.assertEqual(len(self.records()), 3)
+ self.assertTrue(all(r["stdout_excerpt"] == "partial" for r in
self.records()))
+
+ def test_retry_after_seconds_are_respected(self):
+ with mock.patch.object(api.subprocess, "run",
side_effect=[self.failure(retry_after="7"), self.success()]):
+ self.run_get()
+ self.assertEqual(self.sleeps, [7])
+
+ def test_retry_after_dates_and_invalid_values(self):
+ now = datetime(2026, 10, 23, tzinfo=timezone.utc)
+ with mock.patch.object(api, "datetime") as clock:
+ clock.now.return_value = now
+ for value, expected in ((format_datetime(now +
timedelta(seconds=9)), 9),
+ (format_datetime(now -
timedelta(seconds=9)), 2),
+ ("invalid", None), ("-1", None), ("9" *
5000, None)):
+ with self.subTest(value=value[:40]):
+ self.assertEqual(api.retry_delay({"headers":
{"retry-after": value}}, 1), expected)
+
+ def test_long_or_invalid_retry_after_does_not_retry_early(self):
+ for value in ("1000", "bad-date"):
+ with self.subTest(value=value), mock.patch.object(api.subprocess,
"run", side_effect=self.failure(retry_after=value)) as run:
+ with self.assertRaises(subprocess.CalledProcessError):
+ self.run_get()
+ self.assertEqual(run.call_count, 1)
+ self.assertEqual(self.sleeps, [])
+
+ def test_caller_budget_includes_attempts_and_sleep(self):
+ def first(*args, **kwargs):
+ self.now += 3
+ raise self.failure()
+ with mock.patch.object(api.subprocess, "run",
side_effect=[self.failure(), self.success()]) as run:
+ self.run_get(timeout=10)
+ self.assertEqual([c.kwargs["timeout"] for c in run.call_args_list],
[10, 8])
+ self.assertEqual(self.sleeps, [2])
+ self.now = 0
+ with mock.patch.object(api.subprocess, "run", side_effect=first) as
run:
+ with self.assertRaises(subprocess.CalledProcessError):
+ self.run_get(timeout=5)
+ self.assertEqual(run.call_count, 1) # No room for the server/backoff
wait.
+
+ def test_default_budget_caps_each_attempt_and_whole_operation(self):
+ def fail(*args, **kwargs):
+ self.now += 29
+ raise self.failure()
+ with mock.patch.object(api.subprocess, "run", side_effect=fail) as run:
+ with self.assertRaises(subprocess.CalledProcessError):
+ self.run_get()
+ self.assertEqual([c.kwargs["timeout"] for c in run.call_args_list],
[30, 30, 26])
+ self.assertEqual(self.sleeps, [2, 4])
+
+ def test_exhaustion_keeps_all_diagnostics_but_no_partial_stdout(self):
+ with mock.patch.object(api.subprocess, "run",
side_effect=[self.failure() for _ in range(3)]) as run:
+ with self.assertRaises(subprocess.CalledProcessError) as caught:
+ self.run_get()
+ self.assertEqual(run.call_count, 3)
+ self.assertEqual(caught.exception.stdout, "")
+ self.assertEqual(caught.exception.returncode, 1)
+ self.assertIn("retry-test", caught.exception.stderr)
+ self.assertEqual(len(self.records()), 3)
+
+ def test_non_transient_statuses_are_not_retried(self):
+ for status in (400, 401, 403, 404, 422, 429, 500):
+ with self.subTest(status=status),
mock.patch.object(api.subprocess, "run", side_effect=self.failure(status)) as
run:
+ with self.assertRaises(subprocess.CalledProcessError):
+ self.run_get()
+ self.assertEqual(run.call_count, 1)
+ self.assertEqual(self.sleeps, [])
+
+ def test_writes_never_retry_even_if_redirect_ends_in_get(self):
+ commands = [["gh", "pr", "comment", "1", "--body", "x"],
+ [*self.command, "-X", "POST"], [*self.command, "-f",
"state=pending"],
+ [*self.command, "--input", "-"], ["gh", "api", "graphql"]]
+ for command in commands:
+ with self.subTest(command=command),
mock.patch.object(api.subprocess, "run", side_effect=self.failure()) as run:
+ with self.assertRaises(subprocess.CalledProcessError) as
caught:
+ self.run_get(command)
+ self.assertEqual(run.call_count, 1)
+ self.assertEqual(caught.exception.stdout, "partial")
+ self.assertNotIn("timeout", run.call_args.kwargs)
+ self.assertEqual(self.sleeps, [])
+
+ def test_input_and_uncaptured_calls_do_not_retry(self):
+ for options in ({"input": "body"}, {"capture_output": False}):
+ kwargs = dict(check=True, capture_output=True, text=True)
+ kwargs.update(options)
+ with self.subTest(options=options),
mock.patch.object(api.subprocess, "run", side_effect=self.failure()) as run:
+ with self.assertRaises(subprocess.CalledProcessError):
+ api.run_gh(self.command, **kwargs)
+ self.assertEqual(run.call_count, 1)
+
+ def test_timeouts_transport_errors_and_signals_do_not_retry(self):
+ signal = self.failure()
+ signal.returncode = -15
+ for error in (subprocess.TimeoutExpired(self.command, 3,
output="partial", stderr=trace(503)),
+ OSError("transport"), signal,
+ subprocess.CalledProcessError(1, self.command, "",
"connection reset")):
+ with self.subTest(error=type(error)),
mock.patch.object(api.subprocess, "run", side_effect=error) as run:
+ with self.assertRaises(type(error)):
+ self.run_get()
+ self.assertEqual(run.call_count, 1)
+ self.assertEqual(self.sleeps, [])
+
+ def test_sleep_overrun_does_not_start_another_request(self):
+ with mock.patch.object(api.time, "sleep", side_effect=lambda _:
setattr(self, "now", 90)), \
+ mock.patch.object(api.subprocess, "run",
side_effect=self.failure()) as run:
+ with self.assertRaises(subprocess.CalledProcessError):
+ self.run_get()
+ self.assertEqual(run.call_count, 1)
+
+ def test_json_decode_failure_is_not_retried(self):
+ with mock.patch.object(api.subprocess, "run",
return_value=self.success("{")) as run:
+ result = self.run_get()
+ with self.assertRaises(json.JSONDecodeError):
+ api.decode_json(result, self.command)
+ self.assertEqual(run.call_count, 1)
+
+ def test_cli_timeout_returns_124(self):
+ with mock.patch.object(api, "run_gh",
side_effect=subprocess.TimeoutExpired(self.command, 30, stderr="timed out")):
+ self.assertEqual(api.main(), 124)
+
+
[email protected](os.environ.get("RUN_GH_HTTP_TESTS") == "1" and
shutil.which("gh"), "opt-in real gh loopback test")
+class GetRetryHTTPTest(unittest.TestCase):
+ def test_real_get_recovery_pagination_exhaustion_and_write_isolation(self):
+ requests = []
+ class Handler(http.server.BaseHTTPRequestHandler):
+ def log_message(self, *_):
+ pass
+
+ def do_GET(self):
+ parsed = urlsplit(self.path)
+ page = parse_qs(parsed.query).get("page", [None])[0]
+ route = parsed.path + (f"?page={page}" if page is not None
else "")
+ requests.append(("GET", route))
+ count = requests.count(("GET", route))
+ failed = route == "/always" or (count == 1 and route in
("/once", "/pages?page=2"))
+ status = 504 if failed and "page=2" in self.path else 503 if
failed else 200
+ data = b"Service Unavailable" if failed else b'[{"id":2}]' if
"page=2" in self.path else b'[{"id":1}]'
+ self.send_response(status)
+ self.send_header("Content-Type", "text/plain" if failed else
"application/json")
+ self.send_header("Content-Length", str(len(data)))
+ self.send_header("Retry-After", "0")
+ if route == "/pages?page=1":
+ self.send_header("Link",
f'<http://127.0.0.1:{self.server.server_port}/pages?page=2>; rel="next"')
+ self.end_headers()
+ self.wfile.write(data)
+
+ def do_POST(self):
+ requests.append(("POST", self.path))
+ self.send_response(503)
+ self.send_header("Content-Length", "0")
+ self.end_headers()
+
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ with tempfile.TemporaryDirectory() as tmp:
+ env = {"PATH": os.environ["PATH"], "GH_TOKEN": "fake-only",
"GH_ENTERPRISE_TOKEN": "fake-only",
+ "GH_CONFIG_DIR": tmp, "GH_NO_UPDATE_NOTIFIER": "1",
"GH_PROMPT_DISABLED": "1",
+ "REVIEW_GITHUB_DIAGNOSTICS_DIR": tmp}
+ base = f"http://127.0.0.1:{server.server_port}"
+ helper = Path(__file__).with_name("review_github_api.py")
+ def call(path, *args):
+ return subprocess.run([sys.executable, str(helper), "api",
base + path, *args],
+ env=env, capture_output=True,
text=True, timeout=20)
+ result = call("/once")
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(json.loads(result.stdout), [{"id": 1}])
+ self.assertEqual(requests, [("GET", "/once")] * 2)
+ requests.clear()
+ result = call("/pages?page=1", "--paginate", "--slurp")
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(json.loads(result.stdout), [[{"id": 1}],
[{"id": 2}]])
+ self.assertEqual(requests, [("GET", "/pages?page=1"), ("GET",
"/pages?page=2")] * 2)
+ requests.clear()
+ result = call("/always")
+ self.assertEqual(result.returncode, 1)
+ self.assertEqual(result.stdout, "")
+ self.assertEqual(requests, [("GET", "/always")] * 3)
+ requests.clear()
+ result = call("/write", "-X", "POST", "-f", "state=pending")
+ self.assertEqual(result.returncode, 1)
+ self.assertEqual(requests, [("POST", "/write")])
+ records = [json.loads(p.read_text()) for p in
Path(tmp).glob("*.json")]
+ self.assertEqual(len(records), 6)
+ self.assertNotIn("fake-only", json.dumps(records))
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/.github/scripts/test_review_input_failure.py
b/.github/scripts/test_review_input_failure.py
new file mode 100644
index 00000000000..397f45be038
--- /dev/null
+++ b/.github/scripts/test_review_input_failure.py
@@ -0,0 +1,146 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Run actual input/failure steps without a checkout or real GitHub writes."""
+
+import json
+import os
+import re
+import subprocess
+import sys
+import tempfile
+import textwrap
+import unittest
+from pathlib import Path
+
+WORKFLOW = Path(__file__).resolve().parents[1] /
"workflows/code-review-runner.yml"
+COMMENT = "Comment PR on review failure"
+FAIL = "Fail workflow if review failed"
+
+
+def step(name):
+ return re.search(rf"^ - name: {re.escape(name)}\n(.*?)(?=^ -
name:|\Z)",
+ WORKFLOW.read_text(), re.MULTILINE | re.DOTALL).group(1)
+
+
+class InputFailureTest(unittest.TestCase):
+ def setUp(self):
+ tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(tmp.cleanup)
+ self.root = Path(tmp.name)
+ self.calls = self.root / "calls.jsonl"
+ self.output = self.root / "output"
+ self.output.touch()
+ tool = self.root / "gh"
+ tool.write_text(f"#!{sys.executable}\n" + textwrap.dedent("""\
+ import json, os, sys
+ from pathlib import Path
+ with Path(os.environ["GH_CALLS"]).open("a") as handle:
+ handle.write(json.dumps(sys.argv[1:]) + "\\n")
+ if sys.argv[1] == "api":
+ print("gh: HTTP 503", file=sys.stderr)
+ sys.exit(1)
+ sys.exit(int(os.environ.get("COMMENT_EXIT", "0")))
+ """))
+ tool.chmod(0o700)
+ self.context = {"github.repository": "example/repo",
"github.server_url": "https://github.example.test",
+ "github.run_id": "1", "github.event_name":
"issue_comment",
+ "github.event.issue.number": "69322",
"github.event.comment.body": "/review",
+ "steps.review_inputs.outcome": "failure",
"steps.review_context.outcome": "skipped",
+ "steps.review.outcome": "skipped",
"secrets.GITHUB_TOKEN": "fake-only"}
+
+ def run_step(self, name, comment_exit=0):
+ block = step(name)
+ env = dict(os.environ, PATH=f"{self.root}:{os.environ['PATH']}",
+ GH_CALLS=str(self.calls), COMMENT_EXIT=str(comment_exit),
+ GITHUB_OUTPUT=str(self.output),
+
REVIEW_GH_HELPER=str(Path(__file__).with_name("review_github_api.py")),
+ REVIEW_GITHUB_DIAGNOSTICS_DIR=str(self.root /
"diagnostics"))
+ bindings = block.split(" env:\n", 1)[1].split(" run:",
1)[0]
+ for line in bindings.splitlines():
+ key, value = line.strip().split(": ", 1)
+ env[key] = re.sub(r"\$\{\{\s*(.*?)\s*\}\}", lambda m:
self.context.get(m[1], ""), value)
+ script = textwrap.dedent(block.split(" run: |\n", 1)[1])
+ return subprocess.run(["bash", "--noprofile", "--norc", "-eo",
"pipefail", "-c", script],
+ env=env, cwd=self.root, text=True,
capture_output=True, timeout=10)
+
+ def last_comment(self):
+ args = json.loads(self.calls.read_text().splitlines()[-1])
+ self.assertEqual(args[:2], ["pr", "comment"])
+ self.assertEqual(args[args.index("--repo") + 1], "example/repo")
+ return args[2], args[args.index("--body") + 1]
+
+ def test_actual_failed_input_step_still_reports_event_pr(self):
+ result = self.run_step("Resolve review inputs")
+ self.assertEqual(result.returncode, 1)
+ self.assertEqual(self.output.read_text(), "")
+ self.assertFalse((self.root / ".git").exists())
+ result = self.run_step(COMMENT)
+ self.assertEqual(result.returncode, 0, result.stderr)
+ number, body = self.last_comment()
+ self.assertEqual(number, "69322")
+ self.assertIn("'Resolve review inputs' ended with failure", body)
+ result = self.run_step(FAIL)
+ self.assertEqual(result.returncode, 1)
+ self.assertIn("'Resolve review inputs' ended with failure",
result.stdout)
+ sync = step("Sync Code Review check for current head")
+ self.assertIn("steps.review_inputs.outcome == 'success'", sync)
+ self.assertIn("needs.code-review.outputs.head_sha != ''",
WORKFLOW.read_text())
+
+ def test_dispatch_uses_input_pr_without_issue_event(self):
+ self.context.update({"inputs.pr_number": "42",
"github.event.issue.number": ""})
+ self.assertEqual(self.run_step(COMMENT).returncode, 0)
+ self.assertEqual(self.last_comment()[0], "42")
+
+ def
test_resolved_pr_takes_precedence_and_existing_failure_reason_survives(self):
+ self.context.update({"steps.review_inputs.outputs.pr_number": "42",
+ "inputs.pr_number": "43",
"steps.review_inputs.outcome": "success",
+ "steps.auth.outputs.failure_reason": "No eligible
credential"})
+ self.assertEqual(self.run_step(COMMENT).returncode, 0)
+ number, body = self.last_comment()
+ self.assertEqual(number, "42")
+ self.assertIn("No eligible credential", body)
+
+ def test_cancelled_inputs_are_identified(self):
+ self.context["steps.review_inputs.outcome"] = "cancelled"
+ self.assertEqual(self.run_step(COMMENT).returncode, 0)
+ self.assertIn("'Resolve review inputs' ended with cancelled",
self.last_comment()[1])
+ result = self.run_step(FAIL)
+ self.assertEqual(result.returncode, 1)
+ self.assertIn("'Resolve review inputs' ended with cancelled",
result.stdout)
+
+ def test_missing_or_invalid_pr_never_posts(self):
+ for value in ("", "0", "-1", "branch-name", "42\n43"):
+ with self.subTest(value=value):
+ self.context["github.event.issue.number"] = value
+ result = self.run_step(COMMENT)
+ self.assertEqual(result.returncode, 1)
+ self.assertFalse(self.calls.exists())
+ self.assertIn("No valid PR number", result.stdout)
+
+ def
test_failed_notification_is_not_retried_and_terminal_cause_is_preserved(self):
+ result = self.run_step(COMMENT, comment_exit=1)
+ self.assertEqual(result.returncode, 1)
+ self.assertEqual(len(self.calls.read_text().splitlines()), 1)
+ result = self.run_step(FAIL)
+ self.assertEqual(result.returncode, 1)
+ self.assertIn("'Resolve review inputs' ended with failure",
result.stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/.github/scripts/test_submit_review.py
b/.github/scripts/test_submit_review.py
index 715ea0eec2d..130885b347a 100644
--- a/.github/scripts/test_submit_review.py
+++ b/.github/scripts/test_submit_review.py
@@ -906,7 +906,9 @@ class GitHubAPITest(unittest.TestCase):
stderr="", stdout='[[{"id":1}], [{"id":2}]]'
)) as command:
self.assertEqual([{"id": 1}, {"id": 2}], submitter.api("reviews",
lambda: 7, paginated=True))
- self.assertEqual(7, command.call_args.kwargs["timeout"])
+ # The shared GET deadline also accounts for helper setup time.
+ self.assertGreater(command.call_args.kwargs["timeout"], 0)
+ self.assertLessEqual(command.call_args.kwargs["timeout"], 7)
self.assertIn("--paginate", command.call_args.args[0])
self.assertIn("--slurp", command.call_args.args[0])
diff --git a/.github/workflows/code-review-runner.yml
b/.github/workflows/code-review-runner.yml
index cc763b09371..b9a65d83171 100644
--- a/.github/workflows/code-review-runner.yml
+++ b/.github/workflows/code-review-runner.yml
@@ -1088,6 +1088,7 @@ jobs:
timeout-minutes: 2
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ REVIEW_INPUTS_OUTCOME: ${{ steps.review_inputs.outcome }}
AUTH_FAILURE_REASON: ${{ steps.auth.outputs.failure_reason }}
AUTH_INVALID_REASON: ${{ steps.review.outputs.auth_invalid_reason }}
AUTH_INVALID_RECORD_OUTCOME: ${{ steps.auth_invalid_record.outcome }}
@@ -1096,11 +1097,23 @@ jobs:
REVIEW_CONTEXT_OUTCOME: ${{ steps.review_context.outcome }}
REVIEW_FAILURE_REASON: ${{ steps.review.outputs.failure_reason }}
REVIEW_OUTCOME: ${{ steps.review.outcome }}
+ REPO: ${{ github.repository }}
PR_NUMBER: ${{ steps.review_inputs.outputs.pr_number }}
+ INPUT_PR_NUMBER: ${{ inputs.pr_number }}
+ ISSUE_NUMBER: ${{ github.event.issue.number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository
}}/actions/runs/${{ github.run_id }}
run: |
+ # Input resolution can fail before publishing the PR number. Use only
+ # the authorized trigger's numeric PR identity for the failure
notice.
+ PR_NUMBER="${PR_NUMBER:-${INPUT_PR_NUMBER:-$ISSUE_NUMBER}}"
+ if ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
+ echo "::error::No valid PR number is available for the failure
notice."
+ exit 1
+ fi
recovery_msg="Please inspect the workflow logs and rerun the review
after the underlying issue is resolved."
- if [ -n "$AUTH_FAILURE_REASON" ]; then
+ if [ "$REVIEW_INPUTS_OUTCOME" = "failure" ] || [
"$REVIEW_INPUTS_OUTCOME" = "cancelled" ]; then
+ error_msg="Step 'Resolve review inputs' ended with
$REVIEW_INPUTS_OUTCOME; inspect its logs for the original error."
+ elif [ -n "$AUTH_FAILURE_REASON" ]; then
error_msg="$AUTH_FAILURE_REASON"
elif [ "$AUTH_INVALID_REASON" = "refresh_token_reused" ] || [
"$AUTH_INVALID_REASON" = "refresh_token_invalidated" ]; then
error_msg="${REVIEW_FAILURE_REASON:-The selected Codex refresh
token is invalid.}"
@@ -1122,7 +1135,7 @@ jobs:
else
error_msg="${REVIEW_FAILURE_REASON:-Review step was
$REVIEW_OUTCOME (possibly timeout or cancelled)}"
fi
- python3 "$REVIEW_GH_HELPER" pr comment "$PR_NUMBER" --body "$(cat
<<EOF
+ python3 "$REVIEW_GH_HELPER" pr comment "$PR_NUMBER" --repo "$REPO"
--body "$(cat <<EOF
Codex automated review failed and did not complete.
Error: ${error_msg}
@@ -1136,12 +1149,15 @@ jobs:
if: ${{ always() && (steps.review_context.outcome != 'success' ||
steps.review.outcome != 'success') }}
timeout-minutes: 1
env:
+ REVIEW_INPUTS_OUTCOME: ${{ steps.review_inputs.outcome }}
AUTH_FAILURE_REASON: ${{ steps.auth.outputs.failure_reason }}
REVIEW_CONTEXT_OUTCOME: ${{ steps.review_context.outcome }}
REVIEW_FAILURE_REASON: ${{ steps.review.outputs.failure_reason }}
REVIEW_OUTCOME: ${{ steps.review.outcome }}
run: |
- if [ -n "$AUTH_FAILURE_REASON" ]; then
+ if [ "$REVIEW_INPUTS_OUTCOME" = "failure" ] || [
"$REVIEW_INPUTS_OUTCOME" = "cancelled" ]; then
+ error_msg="Step 'Resolve review inputs' ended with
$REVIEW_INPUTS_OUTCOME; inspect its logs for the original error."
+ elif [ -n "$AUTH_FAILURE_REASON" ]; then
error_msg="$AUTH_FAILURE_REASON"
elif [ "$REVIEW_CONTEXT_OUTCOME" != "success" ]; then
error_msg="Review context preparation failed before Codex ran;
inspect the 'Prepare authoritative PR context and required AGENTS guides' step."
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]