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 e498526e6e2 [fix](ci) Preserve failed review submission diagnostics 
(#69154)
e498526e6e2 is described below

commit e498526e6e292ee137292a5c0f188380dbfb13d4
Author: shuke <[email protected]>
AuthorDate: Fri Oct 16 18:41:22 2026 +0800

    [fix](ci) Preserve failed review submission diagnostics (#69154)
    
    ### What problem does this PR solve?
    
    When a GitHub review POST fails, the helper currently discards captured
    stdout/stderr. Its persisted intent correctly prevents reposting, but
    subsequent verification reports only that no final review was found. The
    workflow archives only rejected candidates, leaving the attempted
    submission and original API failure unavailable.
    
    This change records credential-redacted API output, endpoint/method,
    exit status and timeout details for failed commands and invalid JSON
    responses. It retains earlier diagnostics across readback attempts,
    includes the original API error in unverified-delivery failures, and
    uploads the submission intent, rejected candidate and API diagnostics as
    an explicit three-file failure artifact.
    
    Exact GitHub readback, submission ownership, stale-head checks and the
    no-repost guard are unchanged. The tests inject HTTP 422 and timeout
    responses; they do not assert that a historical production failure was
    HTTP 422.
---
 .github/scripts/submit_review.py         |  85 +++++++++++++++--
 .github/scripts/test_submit_review.py    | 154 ++++++++++++++++++++++++++++++-
 .github/workflows/code-review-runner.yml |  11 ++-
 3 files changed, 235 insertions(+), 15 deletions(-)

diff --git a/.github/scripts/submit_review.py b/.github/scripts/submit_review.py
index 358c05a95d4..036d5387c71 100644
--- a/.github/scripts/submit_review.py
+++ b/.github/scripts/submit_review.py
@@ -21,15 +21,19 @@
 import argparse
 import hashlib
 import json
+import os
 import re
 import subprocess
+import sys
 import time
 from collections import Counter
+from datetime import datetime, timezone
 from pathlib import Path
 
 
 SUBMISSION_FILE = "review-submission.json"
 REJECTION_FILE = "review-submission-rejected.json"
+API_ERRORS_FILE = "review-api-errors.jsonl"
 RUN_FILE = "review-run.json"
 RESULT_FILE = "review-result.json"
 PRIORITY = re.compile(r"^\[P([0-3])\]\s+\S")
@@ -91,20 +95,68 @@ def validate_submission(value):
     return value
 
 
+def diagnostic_text(value):
+    if isinstance(value, bytes):
+        value = value.decode("utf-8", errors="replace")
+    value = value or ""
+    # These are the credentials consumed by gh. Artifacts do not inherit the
+    # GitHub Actions log masker, so remove them before persisting API output.
+    for name in ("GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", 
"GITHUB_ENTERPRISE_TOKEN"):
+        token = os.environ.get(name)
+        if token:
+            value = value.replace(token, "[REDACTED]")
+    value = re.sub(r"\b(?:gh[pousr]_[A-Za-z0-9]+|github_pat_[A-Za-z0-9_]+)\b", 
"[REDACTED]", value)
+    return value[:65536] + ("\n[truncated after 65536 characters]" if 
len(value) > 65536 else "")
+
+
+class GitHubAPIError(ValueError):
+    def __init__(self, path, method, error, stdout=None, stderr=None):
+        self.details = {
+            "method": method, "path": path, "error_type": type(error).__name__,
+            "returncode": getattr(error, "returncode", None),
+            "timeout_seconds": getattr(error, "timeout", None),
+            "stdout": diagnostic_text(stdout), "stderr": 
diagnostic_text(stderr),
+        }
+        message = (
+            f"GitHub API {method} {path} failed ({type(error).__name__}, "
+            f"exit={self.details['returncode']}, 
timeout={self.details['timeout_seconds']}); "
+            f"stderr={json.dumps(self.details['stderr'][:1000])}; "
+            f"stdout={json.dumps(self.details['stdout'][:1000])}"
+        )
+        self.details["message"] = message
+        super().__init__(message)
+
+
 def api(path, remaining, *, payload=None, paginated=False):
     command = ["gh", "api", path]
     if paginated:
         command += ["--paginate", "--slurp"]
     if payload is not None:
         command += ["--method", "POST", "--input", "-"]
-    result = subprocess.run(
-        command, input=json.dumps(payload) if payload is not None else None,
-        capture_output=True, text=True, check=True, timeout=min(30, 
remaining()),
-    )
-    value = json.loads(result.stdout)
+    method = "POST" if payload is not None else "GET"
+    try:
+        result = subprocess.run(
+            command, input=json.dumps(payload) if payload is not None else 
None,
+            capture_output=True, text=True, check=True, timeout=min(30, 
remaining()),
+        )
+    except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
+        raise GitHubAPIError(path, method, error, error.stdout, error.stderr) 
from None
+    try:
+        value = json.loads(result.stdout)
+    except json.JSONDecodeError as error:
+        raise GitHubAPIError(path, method, error, result.stdout, 
result.stderr) from None
     return [item for page in value for item in page] if paginated else value
 
 
+def with_api_failure(context, reason):
+    diagnostics = context / API_ERRORS_FILE
+    if diagnostics.exists():
+        with diagnostics.open() as handle:
+            first = json.loads(next(handle))
+        reason += f"; first recorded API failure: {first['message']} (see 
{API_ERRORS_FILE})"
+    return reason
+
+
 def check_target(run, remaining):
     root = f"repos/{run['repository']}/pulls/{run['pr_number']}"
     pr = api(root, remaining)
@@ -160,6 +212,21 @@ def review_payload(submission, run, root, remaining):
 
 
 def verify_completion(context, run, remaining, *, submit=False, 
submission=None):
+    try:
+        return _verify_completion(context, run, remaining, submit=submit, 
submission=submission)
+    except GitHubAPIError as error:
+        diagnostic = dict(error.details, 
recorded_at=datetime.now(timezone.utc).isoformat(),
+                          repository=run["repository"], 
pr_number=run["pr_number"],
+                          head_sha=run["head_sha"], base_sha=run["base_sha"])
+        # Append rather than replace: later readback failures must not erase 
the
+        # original POST response. Neither this log nor the intent proves 
delivery.
+        with (context / API_ERRORS_FILE).open("a") as handle:
+            handle.write(json.dumps(diagnostic) + "\n")
+        print(f"Review API diagnostic: {json.dumps(diagnostic)}", 
file=sys.stderr, flush=True)
+        raise
+
+
+def _verify_completion(context, run, remaining, *, submit=False, 
submission=None):
     """Never infer completion from an unowned review or a local result file."""
     path = context / SUBMISSION_FILE
     if submission is None:
@@ -186,7 +253,9 @@ def verify_completion(context, run, remaining, *, 
submit=False, submission=None)
             write_json(context / REJECTION_FILE, {"reason": reason, 
"submission": submission})
             raise ValueError(reason)
         if path.exists():
-            raise ValueError("Final submission was attempted but cannot be 
verified; refusing to repost")
+            raise ValueError(with_api_failure(
+                context, "Final submission was attempted but cannot be 
verified; refusing to repost"
+            ))
         # Persist the complete intent before POST: if the response is lost, the
         # runner can read back this exact review without repeating the write.
         # Exclusive creation also prevents two accidental simultaneous helper
@@ -197,7 +266,9 @@ def verify_completion(context, run, remaining, *, 
submit=False, submission=None)
         matching = [r for r in api(f"{root}/reviews", remaining, 
paginated=True)
                     if marker in (r.get("body") or "")]
     if len(matching) != 1:
-        raise ValueError("Could not verify exactly one final review from this 
invocation")
+        raise ValueError(with_api_failure(
+            context, "Could not verify exactly one final review from this 
invocation"
+        ))
     review = matching[0]
     expected_state = "CHANGES_REQUESTED" if payload["event"] == 
"REQUEST_CHANGES" else "COMMENTED"
     if (review.get("user", {}).get("login") != "github-actions[bot]"
diff --git a/.github/scripts/test_submit_review.py 
b/.github/scripts/test_submit_review.py
index 546bfecede6..42735555b29 100644
--- a/.github/scripts/test_submit_review.py
+++ b/.github/scripts/test_submit_review.py
@@ -52,6 +52,7 @@ class FinalReviewTest(unittest.TestCase):
         self.comments = []
         self.existing = []
         self.posts = []
+        self.real_api = submitter.api
         self.api = self.enterContext(mock.patch.object(submitter, "api", 
side_effect=self.fake_api))
         self.remaining = lambda: 60
 
@@ -90,6 +91,117 @@ class FinalReviewTest(unittest.TestCase):
     def verify(self):
         return submitter.verify_completion(self.context, self.run, 
self.remaining)
 
+    def gh_response(self, command, **kwargs):
+        payload = json.loads(kwargs["input"]) if kwargs.get("input") is not 
None else None
+        paginated = "--paginate" in command
+        value = self.fake_api(command[2], self.remaining, payload=payload, 
paginated=paginated)
+        return SimpleNamespace(stdout=json.dumps([value] if paginated else 
value), stderr="")
+
+    def test_failed_post_retains_response_candidate_and_no_repost_guard(self):
+        self.api.side_effect = self.real_api
+        rejection = '{"message":"Validation 
Failed","errors":[{"field":"position"}]}'
+        post_attempts = []
+
+        def reject_post(command, **kwargs):
+            if kwargs.get("input") is not None:
+                post_attempts.append(json.loads(kwargs["input"]))
+                raise subprocess.CalledProcessError(1, command, 
output=rejection,
+                                                    stderr="gh: Validation 
Failed (HTTP 422)")
+            return self.gh_response(command, **kwargs)
+
+        with mock.patch.object(submitter.subprocess, "run", 
side_effect=reject_post), \
+             redirect_stderr(io.StringIO()) as logs:
+            with self.assertRaisesRegex(ValueError, "HTTP 422"):
+                self.submit()
+            for call, reason in ((self.submit, "refusing to repost"), 
(self.verify, "exactly one")):
+                with self.assertRaisesRegex(ValueError, reason) as failure:
+                    call()
+                self.assertIn("HTTP 422", str(failure.exception))
+                self.assertIn("Validation Failed", str(failure.exception))
+        self.assertEqual(1, len(post_attempts))
+        self.assertEqual(self.submission(), json.loads((self.context / 
submitter.SUBMISSION_FILE).read_text()))
+        diagnostic = json.loads((self.context / 
submitter.API_ERRORS_FILE).read_text())
+        self.assertEqual(("POST", self.root + "/reviews", 1),
+                         (diagnostic["method"], diagnostic["path"], 
diagnostic["returncode"]))
+        self.assertEqual(rejection, diagnostic["stdout"])
+        self.assertEqual(self.run["head_sha"], diagnostic["head_sha"])
+        self.assertIn("HTTP 422", logs.getvalue())
+        self.assertFalse((self.context / submitter.RESULT_FILE).exists())
+
+    def 
test_later_api_failure_does_not_overwrite_original_post_diagnostic(self):
+        self.api.side_effect = self.real_api
+
+        def reject_post(command, **kwargs):
+            if kwargs.get("input") is not None:
+                raise subprocess.CalledProcessError(1, command, stderr="HTTP 
422: position invalid")
+            return self.gh_response(command, **kwargs)
+
+        with mock.patch.object(submitter.subprocess, "run", 
side_effect=reject_post) as gh, \
+             redirect_stderr(io.StringIO()):
+            with self.assertRaisesRegex(ValueError, "HTTP 422"):
+                self.submit()
+            gh.side_effect = subprocess.CalledProcessError(1, "gh", 
stderr="HTTP 502: read unavailable")
+            with self.assertRaisesRegex(ValueError, "HTTP 502"):
+                self.verify()
+            gh.side_effect = self.gh_response
+            with self.assertRaisesRegex(ValueError, "first recorded API 
failure.*HTTP 422"):
+                self.verify()
+        records = [json.loads(line) for line in (self.context / 
submitter.API_ERRORS_FILE).read_text().splitlines()]
+        self.assertEqual(["POST", "GET"], [record["method"] for record in 
records])
+
+    def test_timed_out_post_still_recovers_by_exact_readback(self):
+        self.api.side_effect = self.real_api
+
+        def lose_post_response(command, **kwargs):
+            result = self.gh_response(command, **kwargs)
+            if kwargs.get("input") is not None:
+                raise subprocess.TimeoutExpired(command, 30, output=b'partial 
response', stderr=b'timed out')
+            return result
+
+        with mock.patch.object(submitter.subprocess, "run", 
side_effect=lose_post_response), \
+             redirect_stderr(io.StringIO()):
+            with self.assertRaisesRegex(ValueError, "TimeoutExpired"):
+                self.submit()
+            self.assertEqual("failure", self.submit()["state"])
+            self.assertEqual("failure", self.verify()["state"])
+        self.assertEqual(1, len(self.posts))
+        diagnostic = json.loads((self.context / 
submitter.API_ERRORS_FILE).read_text())
+        self.assertEqual((30, "partial response", "timed out"),
+                         (diagnostic["timeout_seconds"], diagnostic["stdout"], 
diagnostic["stderr"]))
+
+    def test_runner_failure_keeps_original_post_error(self):
+        (self.context / "codex_goal_prompt.txt").write_text("Review the PR")
+        args = SimpleNamespace(context_dir=self.context, cwd=self.context, **{
+            k: self.run[k] for k in ("repository", "pr_number", "head_sha", 
"base_sha")
+        }, model="gpt-6-sol", effort="xhigh", budget_seconds=60)
+        self.api.side_effect = self.real_api
+
+        def reject_post(command, **kwargs):
+            if kwargs.get("input") is not None:
+                raise subprocess.CalledProcessError(1, command, stderr="gh: 
Validation Failed (HTTP 422)")
+            return self.gh_response(command, **kwargs)
+
+        def attempt(command, events, stderr, timeout, reaper=None, 
watchdog=None):
+            run = json.loads((self.context / submitter.RUN_FILE).read_text())
+            with self.assertRaisesRegex(ValueError, "HTTP 422"):
+                submitter.verify_completion(self.context, run, self.remaining, 
submit=True,
+                                            submission=self.submission())
+            events.write_text(json.dumps({"type": "thread.started", 
"thread_id":
+                "0199a213-81c0-7800-8aa1-bbab2a035a53"}) + "\n" +
+                json.dumps({"type": "turn.completed"}) + "\n")
+            stderr.write_text("")
+            return 0
+
+        with mock.patch.object(runner, "run_attempt", side_effect=attempt), \
+             mock.patch.object(runner.HeadWatchdog, "check"), \
+             mock.patch.object(submitter.subprocess, "run", 
side_effect=reject_post), \
+             redirect_stderr(io.StringIO()):
+            self.assertEqual(1, runner.run_review(args))
+        error = runner.read_events(self.context / 
"codex-events.jsonl")[-1]["error"]["message"]
+        self.assertIn("HTTP 422", error)
+        self.assertIn(submitter.API_ERRORS_FILE, error)
+        self.assertFalse((self.context / submitter.RESULT_FILE).exists())
+
     def test_submits_once_and_verifies_same_complete_review(self):
         result = self.submit()
         self.assertEqual("failure", result["state"])
@@ -252,7 +364,7 @@ class FinalReviewTest(unittest.TestCase):
             k: self.run[k] for k in ("repository", "pr_number", "head_sha", 
"base_sha")
         }, model="gpt-6-sol", effort="xhigh", budget_seconds=60)
 
-        def attempt(command, events, stderr, timeout, reaper=None):
+        def attempt(command, events, stderr, timeout, reaper=None, 
watchdog=None):
             run = json.loads((self.context / submitter.RUN_FILE).read_text())
             self.reviews = [{"user": {"login": "github-actions[bot]"},
                              "commit_id": run["head_sha"], "submitted_at": 
run["started_at"]}]
@@ -267,6 +379,7 @@ class FinalReviewTest(unittest.TestCase):
             return 0
 
         with mock.patch.object(runner, "run_attempt", side_effect=attempt), \
+             mock.patch.object(runner.HeadWatchdog, "check"), \
              mock.patch.object(runner, "check_resume_target") as guard, \
              redirect_stderr(io.StringIO()):
             self.assertEqual(1, runner.run_review(args))
@@ -442,7 +555,7 @@ class FinalReviewTest(unittest.TestCase):
 
 
 class ReviewWorkflowTest(unittest.TestCase):
-    def test_queue_is_per_pr_and_rejected_artifact_is_explicit(self):
+    def test_queue_is_per_pr_and_failure_artifact_is_explicit(self):
         workflow = (Path(__file__).resolve().parents[1] / 
"workflows/code-review-runner.yml").read_text()
         review_job = workflow.split("  code-review:\n", 1)[1].split("  
aggregate-status:", 1)[0]
         concurrency = re.search(r"^    concurrency:\n((?:      .*\n)+)", 
review_job, re.MULTILINE)
@@ -452,11 +565,14 @@ class ReviewWorkflowTest(unittest.TestCase):
         self.assertIn("queue: max", concurrency[1])
         self.assertNotIn("sha", concurrency[1])
         self.assertNotRegex(workflow, r"(?m)^concurrency:")
-        artifact = review_job.split("      - name: Preserve rejected final 
review\n", 1)[1]
+        artifact = review_job.split("      - name: Preserve failed final 
review diagnostics\n", 1)[1]
         artifact = artifact.split("      - name:", 1)[0]
         self.assertIn("always()", artifact)
         self.assertIn("steps.review.outcome == 'failure'", artifact)
-        self.assertIn("path: ${{ env.REVIEW_CONTEXT_DIR }}/" + 
submitter.REJECTION_FILE, artifact)
+        paths = artifact.split("          path: |\n", 1)[1].split("          
#", 1)[0]
+        self.assertEqual({"${{ env.REVIEW_CONTEXT_DIR }}/" + name for name in (
+            submitter.SUBMISSION_FILE, submitter.REJECTION_FILE, 
submitter.API_ERRORS_FILE
+        )}, {line.strip() for line in paths.splitlines()})
         self.assertIn("include-hidden-files: true", artifact)
         self.assertIn("if-no-files-found: ignore", artifact)
 
@@ -471,6 +587,36 @@ class GitHubAPITest(unittest.TestCase):
         self.assertIn("--paginate", command.call_args.args[0])
         self.assertIn("--slurp", command.call_args.args[0])
 
+    def test_api_error_redacts_credentials_before_logging_or_archiving(self):
+        secret = "private-token-from-environment"
+        error = subprocess.CalledProcessError(1, "gh", 
output='{"message":"invalid ghp_exampleToken"}',
+                                            stderr=f"HTTP 403: token={secret}")
+        with mock.patch.dict(submitter.os.environ, {"GH_TOKEN": secret}), \
+             mock.patch.object(submitter.subprocess, "run", 
side_effect=error), \
+             self.assertRaises(submitter.GitHubAPIError) as failure:
+            submitter.api("reviews", lambda: 60, payload={"body": "not a 
credential"})
+        text = json.dumps(failure.exception.details) + str(failure.exception)
+        self.assertNotIn(secret, text)
+        self.assertNotIn("ghp_exampleToken", text)
+        self.assertIn("[REDACTED]", text)
+        self.assertIn("HTTP 403", text)
+        self.assertNotIn("not a credential", text)
+
+    def test_invalid_json_retains_successful_command_output(self):
+        with mock.patch.object(submitter.subprocess, "run", 
return_value=SimpleNamespace(
+            stdout="incomplete response", stderr="diagnostic"
+        )), self.assertRaises(submitter.GitHubAPIError) as failure:
+            submitter.api("reviews", lambda: 7)
+        self.assertEqual("JSONDecodeError", 
failure.exception.details["error_type"])
+        self.assertEqual("incomplete response", 
failure.exception.details["stdout"])
+
+    def test_diagnostics_bound_large_output_after_redaction(self):
+        output = "ghp_secret " + "x" * 70000
+        result = submitter.diagnostic_text(output.encode())
+        self.assertNotIn("ghp_secret", result)
+        self.assertLess(len(result), 65600)
+        self.assertTrue(result.endswith("[truncated after 65536 characters]"))
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/.github/workflows/code-review-runner.yml 
b/.github/workflows/code-review-runner.yml
index 231a7a01fce..3f61b66a669 100644
--- a/.github/workflows/code-review-runner.yml
+++ b/.github/workflows/code-review-runner.yml
@@ -979,15 +979,18 @@ jobs:
             
"review_state=\(.state)\np0=\(.p0)\np1=\(.p1)\nconverged=\(.converged)\nrounds=\(.rounds)"'
 \
             "$REVIEW_CONTEXT_DIR/review-result.json" >> "$GITHUB_OUTPUT"
 
-      - name: Preserve rejected final review
+      - name: Preserve failed final review diagnostics
         if: ${{ always() && steps.review_context.outcome == 'success' && 
steps.review.outcome == 'failure' }}
         continue-on-error: true
         timeout-minutes: 2
         uses: actions/upload-artifact@v4
         with:
-          name: rejected-code-review-${{ github.run_id }}-${{ 
github.run_attempt }}
-          path: ${{ env.REVIEW_CONTEXT_DIR }}/review-submission-rejected.json
-          # The context directory is hidden; include only the explicit JSON 
above.
+          name: failed-code-review-${{ github.run_id }}-${{ github.run_attempt 
}}
+          path: |
+            ${{ env.REVIEW_CONTEXT_DIR }}/review-submission.json
+            ${{ env.REVIEW_CONTEXT_DIR }}/review-submission-rejected.json
+            ${{ env.REVIEW_CONTEXT_DIR }}/review-api-errors.jsonl
+          # The context directory is hidden; include only these explicit files.
           include-hidden-files: true
           if-no-files-found: ignore
           retention-days: 14


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to