mirror of
https://github.com/apache/doris.git
synced 2026-11-04 07:31:17 +00:00
### What problem does this PR solve? Related PR: #69386 Problem Summary: When the pending-status API call fails before repository checkout, the failure notification runs outside a Git checkout. Without an explicit repository, `gh pr comment` fails with `fatal: not a git repository`; both the notification and terminal failure step also misidentify the skipped context preparation step as the cause. This occurred in [run 38864234303](https://github.com/apache/doris/actions/runs/38864234303/job/116853272967). Pass `--repo` explicitly and report failed/cancelled pending-status and checkout steps by name. Distinguish actual context failure/cancellation from skipped or unstarted context in both reporting paths, preserving the existing auth/usage-limit recovery messages and failing exit status.
172 lines
7.4 KiB
Python
172 lines
7.4 KiB
Python
#!/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.
|
|
|
|
"""Exercise failure reporting before checkout using the actual workflow shell."""
|
|
|
|
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 workflow_step(name):
|
|
match = re.search(
|
|
rf"^ - name: {re.escape(name)}\n(.*?)(?=^ - name:|\Z)",
|
|
WORKFLOW.read_text(), re.MULTILINE | re.DOTALL,
|
|
)
|
|
if match is None:
|
|
raise AssertionError(f"Missing workflow step: {name}")
|
|
return match.group(1)
|
|
|
|
|
|
class ReviewFailureNotificationTest(unittest.TestCase):
|
|
def setUp(self):
|
|
temp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(temp.cleanup)
|
|
self.root = Path(temp.name)
|
|
fake_gh = self.root / "gh"
|
|
fake_gh.write_text(f"#!{sys.executable}\n" + textwrap.dedent('''\
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
Path(os.environ["GH_CALL"]).write_text(json.dumps(sys.argv[1:]))
|
|
sys.exit(int(os.environ.get("GH_EXIT", "0")))
|
|
'''))
|
|
fake_gh.chmod(0o700)
|
|
self.context = {
|
|
"github.repository": "example/repo",
|
|
"github.server_url": "https://github.example.test",
|
|
"github.run_id": "1234",
|
|
"secrets.GITHUB_TOKEN": "fake-token",
|
|
"steps.review_inputs.outputs.pr_number": "42",
|
|
"steps.pending_status.outcome": "success",
|
|
"steps.checkout.outcome": "success",
|
|
"steps.review_context.outcome": "skipped",
|
|
"steps.review.outcome": "skipped",
|
|
}
|
|
|
|
def run_step(self, name, gh_exit=0):
|
|
step = workflow_step(name)
|
|
self.assertIn("always()", step)
|
|
env = {
|
|
**os.environ,
|
|
"PATH": f"{self.root}{os.pathsep}{os.environ['PATH']}",
|
|
"GH_CALL": str(self.root / "gh-call.json"),
|
|
"GH_EXIT": str(gh_exit),
|
|
}
|
|
# Render the real env bindings, so a missing/wrong outcome binding fails
|
|
# the behavioral tests instead of being hidden by injected shell vars.
|
|
bindings = step.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 match: self.context.get(match.group(1), ""), value,
|
|
)
|
|
env["REVIEW_GH_HELPER"] = str(Path(__file__).with_name("review_github_api.py"))
|
|
env["REVIEW_GITHUB_DIAGNOSTICS_DIR"] = str(self.root / "diagnostics")
|
|
script = textwrap.dedent(step.split(" run: |\n", 1)[1])
|
|
result = subprocess.run(
|
|
["bash", "--noprofile", "--norc", "-eo", "pipefail", "-c", script],
|
|
env=env, cwd=self.root, capture_output=True, text=True, timeout=10,
|
|
)
|
|
self.assertEqual(result.returncode, gh_exit if name == COMMENT else 1,
|
|
result.stdout + result.stderr)
|
|
if name == COMMENT:
|
|
args = json.loads((self.root / "gh-call.json").read_text())
|
|
self.assertEqual(args[:3], ["pr", "comment", "42"])
|
|
self.assertIn("--repo", args)
|
|
self.assertEqual(args[args.index("--repo") + 1], "example/repo")
|
|
body = args[args.index("--body") + 1]
|
|
self.assertIn("https://github.example.test/example/repo/actions/runs/1234", body)
|
|
return body
|
|
return result.stdout
|
|
|
|
def assert_both_report(self, expected, *, gh_exit=0):
|
|
for name in (COMMENT, FAIL):
|
|
with self.subTest(step=name):
|
|
self.assertIn(expected, self.run_step(name, gh_exit=gh_exit))
|
|
|
|
def test_pending_status_failure_before_checkout(self):
|
|
self.context.update({
|
|
"steps.pending_status.outcome": "failure",
|
|
"steps.checkout.outcome": "skipped",
|
|
})
|
|
self.assertFalse((self.root / ".git").exists())
|
|
self.assert_both_report("'Mark Code Review status as pending' ended with failure")
|
|
|
|
def test_checkout_failure_with_pending_success_or_intentionally_skipped(self):
|
|
self.context["steps.checkout.outcome"] = "failure"
|
|
for outcome in ("success", "skipped"):
|
|
with self.subTest(pending=outcome):
|
|
self.context["steps.pending_status.outcome"] = outcome
|
|
self.assert_both_report("'Checkout repository' ended with failure")
|
|
|
|
def test_cancelled_early_steps_are_identified(self):
|
|
for step, label in (("pending_status", "Mark Code Review status as pending"),
|
|
("checkout", "Checkout repository")):
|
|
with self.subTest(step=step):
|
|
self.context[f"steps.{step}.outcome"] = "cancelled"
|
|
self.assert_both_report(f"'{label}' ended with cancelled")
|
|
self.context[f"steps.{step}.outcome"] = "success"
|
|
|
|
def test_skipped_or_unstarted_context_does_not_claim_context_failure(self):
|
|
for outcome in ("skipped", ""):
|
|
with self.subTest(context=outcome):
|
|
self.context["steps.review_context.outcome"] = outcome
|
|
self.assert_both_report("Review setup stopped before context preparation ran")
|
|
|
|
def test_actual_context_failure_and_cancellation(self):
|
|
for outcome, message in (("failure", "Review context preparation failed"),
|
|
("cancelled", "Review context preparation was cancelled")):
|
|
with self.subTest(context=outcome):
|
|
self.context["steps.review_context.outcome"] = outcome
|
|
self.assert_both_report(message)
|
|
|
|
def test_auth_and_review_reasons_are_preserved(self):
|
|
self.context["steps.auth.outputs.failure_reason"] = "No eligible Codex account"
|
|
self.assert_both_report("No eligible Codex account")
|
|
self.context.pop("steps.auth.outputs.failure_reason")
|
|
self.context.update({
|
|
"steps.review_context.outcome": "success",
|
|
"steps.review.outcome": "failure",
|
|
"steps.review.outputs.failure_reason": "Final review verification failed",
|
|
})
|
|
self.assert_both_report("Final review verification failed")
|
|
|
|
def test_notification_failure_does_not_replace_terminal_cause(self):
|
|
self.context.update({
|
|
"steps.pending_status.outcome": "failure",
|
|
"steps.checkout.outcome": "skipped",
|
|
})
|
|
self.assert_both_report("'Mark Code Review status as pending' ended with failure", gh_exit=7)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|