mirror of
https://github.com/GreptimeTeam/greptimedb.git
synced 2026-10-03 18:45:35 +00:00
* fix(ci): repair draft PR command dispatch Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(ci): dispatch command workflows by branch ref Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(ci): admit PR authors and writers for CI commands Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(ci): preserve dispatch guard dependency semantics Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(ci): configure slash command permissions individually Signed-off-by: WenyXu <wenymedia@gmail.com> * refactor(ci): run command workflows at dispatch branch head Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(ci): rerun fork PR checks and report command failures Signed-off-by: WenyXu <wenymedia@gmail.com> --------- Signed-off-by: WenyXu <wenymedia@gmail.com>
205 lines
12 KiB
Python
205 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
# Copyright 2023 Greptime Team
|
|
#
|
|
# Licensed 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 importlib.util
|
|
import json
|
|
import os
|
|
import tempfile
|
|
import subprocess
|
|
import textwrap
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
SCRIPT = Path(__file__).parents[1] / "ci-slash.py"
|
|
spec = importlib.util.spec_from_file_location("ci_slash", SCRIPT)
|
|
assert spec and spec.loader
|
|
ci = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(ci)
|
|
|
|
|
|
OPTIONS_TO_TEST = ["/ci", "/ci rust", "/ci fuzz chaos", "/ci fuzz all"]
|
|
|
|
|
|
class CiSlashTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.output = tempfile.NamedTemporaryFile(delete=False)
|
|
self.output.close()
|
|
self.env = {
|
|
"GITHUB_OUTPUT": self.output.name,
|
|
"GITHUB_REPOSITORY": "GreptimeTeam/greptimedb",
|
|
"GITHUB_API_URL": "https://api.github.test",
|
|
"GITHUB_TOKEN": "token",
|
|
"COMMENT_ID": "1",
|
|
"DISPATCH_SENDER": "github-actions[bot]",
|
|
"DISPATCH_HEAD_SHA": "a" * 40,
|
|
}
|
|
|
|
def tearDown(self):
|
|
os.unlink(self.output.name)
|
|
|
|
def run_main(self, responses):
|
|
with patch.dict(os.environ, self.env, clear=False), patch.object(ci, "api", side_effect=responses):
|
|
self.assertEqual(0, ci.main())
|
|
return Path(self.output.name).read_text()
|
|
|
|
def test_help_needs_no_pr_lookup(self):
|
|
output = self.run_main([{"body": "/ci help", "issue_url": "https://api.github.test/repos/GreptimeTeam/greptimedb/issues/42"}])
|
|
self.assertIn("skip=true", output)
|
|
self.assertIn("Available draft-PR CI commands", output)
|
|
|
|
def test_dispatches_full_suite_with_top_level_admin_permission(self):
|
|
output = self.run_main([
|
|
{"body": "/ci", "issue_url": "https://api.github.test/repos/GreptimeTeam/greptimedb/issues/42", "user": {"login": "admin"}},
|
|
{"state": "open", "draft": True, "head": {"sha": "a" * 40, "ref": "fix/draft-ci", "repo": {"full_name": "GreptimeTeam/greptimedb"}}},
|
|
{"permission": "admin", "user": {"login": "admin"}},
|
|
])
|
|
self.assertIn("skip=false", output)
|
|
self.assertIn("head_ref=fix/draft-ci", output)
|
|
self.assertIn("workflow=rust.yml,integration.yml,checks.yml,docs.yml", output)
|
|
|
|
def test_rejects_non_draft_before_permission_lookup(self):
|
|
output = self.run_main([
|
|
{"body": "/ci fuzz chaos", "issue_url": "https://api.github.test/repos/GreptimeTeam/greptimedb/issues/42", "user": {"login": "admin"}},
|
|
{"state": "open", "draft": False, "head": {"sha": "a" * 40, "ref": "fix/draft-ci", "repo": {"full_name": "GreptimeTeam/greptimedb"}}},
|
|
])
|
|
self.assertIn("skip=true", output)
|
|
self.assertIn("PR must be open and draft", output)
|
|
|
|
def test_rejects_missing_head_ref_before_permission_lookup(self):
|
|
output = self.run_main([
|
|
{"body": "/ci", "issue_url": "https://api.github.test/repos/GreptimeTeam/greptimedb/issues/42", "user": {"login": "admin"}},
|
|
{"state": "open", "draft": True, "head": {"sha": "a" * 40, "repo": {"full_name": "GreptimeTeam/greptimedb"}}},
|
|
])
|
|
self.assertIn("skip=true", output)
|
|
self.assertIn("PR head changed; comment again.", output)
|
|
|
|
def test_permission_matrix(self):
|
|
for command in OPTIONS_TO_TEST:
|
|
for author in (True, False):
|
|
for permission in ("admin", "maintain", "write", "triage", "read", "none", None):
|
|
with self.subTest(command=command, author=author, permission=permission):
|
|
Path(self.output.name).write_text("")
|
|
responses = [
|
|
{"body": command, "issue_url": "https://api.github.test/repos/GreptimeTeam/greptimedb/issues/42", "user": {"login": "actor"}},
|
|
{"state": "open", "draft": True, "user": {"login": "actor" if author else "other"}, "head": {"sha": "a" * 40, "ref": "fix/draft-ci", "repo": {"full_name": "GreptimeTeam/greptimedb"}}},
|
|
]
|
|
if command == "/ci fuzz all" or not author:
|
|
responses.append({"permission": permission})
|
|
output = self.run_main(responses)
|
|
allowed = permission == "admin" if command == "/ci fuzz all" else author or permission in ("admin", "maintain", "write")
|
|
self.assertIn("skip=false" if allowed else "skip=true", output)
|
|
|
|
def test_reply_uses_issue_comment_endpoint(self):
|
|
workflow = SCRIPT.parents[1] / "workflows" / "ci-slash.yml"
|
|
step = workflow.read_text().split(" - name: Reply with command result\n", 1)[1]
|
|
command = textwrap.dedent(step.split(" run: |\n", 1)[1])
|
|
reply = "Denied: `example`\nSecond line with $variables and 'quotes'"
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
gh = Path(directory) / "gh"
|
|
gh.write_text("#!/usr/bin/env python3\nimport json, sys\nprint(json.dumps(sys.argv[1:]))\n")
|
|
gh.chmod(0o755)
|
|
env = {**os.environ, "PATH": directory + os.pathsep + os.environ["PATH"], "GITHUB_REPOSITORY": "GreptimeTeam/greptimedb", "PR_NUMBER": "42", "REPLY": reply, "ADMIT_OUTCOME": "success", "DISPATCH_OUTCOME": "success"}
|
|
result = subprocess.run(["bash", "-eu", "-c", command], env=env, check=True, capture_output=True, text=True)
|
|
self.assertEqual(json.loads(result.stdout), ["api", "--method", "POST", "/repos/GreptimeTeam/greptimedb/issues/42/comments", "-f", "body=" + reply])
|
|
|
|
def test_failure_reply_and_partial_dispatch(self):
|
|
workflow = (SCRIPT.parents[1] / "workflows" / "ci-slash.yml").read_text()
|
|
dispatch = textwrap.dedent(workflow.split(" - name: Dispatch selected CI workflow\n", 1)[1].split(" run: |\n", 1)[1].split(" - name: Reply", 1)[0])
|
|
reply = textwrap.dedent(workflow.split(" - name: Reply with command result\n", 1)[1].split(" run: |\n", 1)[1])
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
gh = Path(directory) / "gh"
|
|
log = Path(directory) / "calls"
|
|
gh.write_text("#!/usr/bin/env python3\nimport json, os, sys\nwith open(os.environ['CALLS'], 'a') as f: f.write(json.dumps(sys.argv[1:]) + '\\n')\nsys.exit(1 if '/runs/8/rerun' in sys.argv[-1] else 0)\n")
|
|
gh.chmod(0o755)
|
|
env = {**os.environ, "PATH": directory + os.pathsep + os.environ["PATH"], "CALLS": str(log), "GITHUB_REPOSITORY": "GreptimeTeam/greptimedb", "RUN_IDS": "7,8,9", "PR_NUMBER": "42", "REPLY": "Success", "ADMIT_OUTCOME": "success", "DISPATCH_OUTCOME": "failure", "GITHUB_SERVER_URL": "https://github.com", "GITHUB_RUN_ID": "123"}
|
|
result = subprocess.run(["bash", "-eu", "-c", dispatch], env=env, capture_output=True)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
subprocess.run(["bash", "-eu", "-c", reply], env=env, check=True, capture_output=True)
|
|
calls = [json.loads(line) for line in log.read_text().splitlines()]
|
|
self.assertEqual([call[-1] for call in calls[:2]], ["/repos/GreptimeTeam/greptimedb/actions/runs/7/rerun", "/repos/GreptimeTeam/greptimedb/actions/runs/8/rerun"])
|
|
self.assertIn("CI trigger failed; some workflows may already have been requested", calls[2][-1])
|
|
self.assertIn("/actions/runs/123", calls[2][-1])
|
|
self.assertIn("!cancelled()", workflow.split(" - name: Reply with command result", 1)[1])
|
|
|
|
def test_fork_rerun_gates_cover_standard_jobs(self):
|
|
import re
|
|
for name, count in [("rust", 7), ("integration", 5), ("checks", 5), ("docs", 3)]:
|
|
workflow = (SCRIPT.parents[1] / "workflows" / (name + ".yml")).read_text()
|
|
gates = re.findall(r"^ if:.*github.run_attempt > 1.*$", workflow, re.MULTILINE)
|
|
self.assertEqual(len(gates), count, name)
|
|
self.assertTrue(all("always()" not in gate for gate in gates))
|
|
|
|
def test_command_permissions(self):
|
|
workflow = SCRIPT.parents[1] / "workflows" / "slash-command-dispatch.yml"
|
|
config = workflow.read_text().split("config: >-", 1)[1].split(" - name:", 1)[0]
|
|
self.assertEqual(json.loads(config), [
|
|
{"command": "query-regression", "permission": "admin", "issue_type": "pull-request"},
|
|
{"command": "ci", "permission": "none", "issue_type": "pull-request"},
|
|
])
|
|
|
|
def test_fork_requires_writer_even_for_author(self):
|
|
for permission in ("read", "write", "maintain", "admin"):
|
|
with self.subTest(permission=permission):
|
|
Path(self.output.name).write_text("")
|
|
responses = [
|
|
{"body": "/ci", "issue_url": "https://api.github.test/issues/42", "user": {"login": "actor"}},
|
|
{"state": "open", "draft": True, "user": {"login": "actor"}, "head": {"sha": "a" * 40, "ref": "fork-branch", "repo": {"full_name": "actor/greptimedb"}}},
|
|
{"permission": permission},
|
|
]
|
|
if permission != "read":
|
|
responses.append({"workflow_runs": [
|
|
{"id": 7, "path": ".github/workflows/rust.yml", "head_sha": "a" * 40, "head_branch": "fork-branch", "head_repository": {"full_name": "actor/greptimedb"}, "status": "completed", "conclusion": "skipped"},
|
|
{"id": 8, "path": ".github/workflows/checks.yml", "head_sha": "a" * 40, "head_branch": "fork-branch", "head_repository": {"full_name": "someone/greptimedb"}, "status": "completed", "conclusion": "skipped"},
|
|
]})
|
|
output = self.run_main(responses)
|
|
if permission == "read":
|
|
self.assertIn("skip=true", output)
|
|
else:
|
|
self.assertIn("run_ids=7\n", output)
|
|
self.assertNotIn("head_ref=", output)
|
|
|
|
def test_fork_rejects_non_draft_original_run(self):
|
|
output = self.run_main([
|
|
{"body": "/ci rust", "issue_url": "https://api.github.test/issues/42", "user": {"login": "writer"}},
|
|
{"state": "open", "draft": True, "head": {"sha": "a" * 40, "ref": "fork-branch", "repo": {"full_name": "actor/greptimedb"}}},
|
|
{"permission": "write"},
|
|
{"workflow_runs": [{"id": 7, "path": ".github/workflows/rust.yml@main", "head_sha": "a" * 40, "head_branch": "fork-branch", "head_repository": {"full_name": "actor/greptimedb"}, "status": "completed", "conclusion": "success", "run_attempt": 2}]},
|
|
{"conclusion": "success"},
|
|
])
|
|
self.assertIn("only originally skipped draft CI runs", output)
|
|
self.assertIn("skip=true", output)
|
|
|
|
def test_author_cannot_bypass_pr_guards(self):
|
|
for change, reason in [
|
|
({"state": "closed"}, "PR must be open and draft"),
|
|
({"draft": False}, "PR must be open and draft"),
|
|
({"head": {"sha": "b" * 40, "ref": "fix/draft-ci", "repo": {"full_name": "GreptimeTeam/greptimedb"}}}, "PR head changed"),
|
|
]:
|
|
with self.subTest(change=change):
|
|
Path(self.output.name).write_text("")
|
|
pr = {"state": "open", "draft": True, "user": {"login": "actor"}, "head": {"sha": "a" * 40, "ref": "fix/draft-ci", "repo": {"full_name": "GreptimeTeam/greptimedb"}}}
|
|
pr.update(change)
|
|
output = self.run_main([
|
|
{"body": "/ci rust", "issue_url": "https://api.github.test/repos/GreptimeTeam/greptimedb/issues/42", "user": {"login": "actor"}},
|
|
pr,
|
|
])
|
|
self.assertIn("skip=true", output)
|
|
self.assertIn(reason, output)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|