[CI] Add comment-based Buildkite triggers (#50132)

Signed-off-by: khluu <khluu000@gmail.com> Co-authored-by: OpenAI Codex <codex@openai.com>

Kevin H. Luu committed Jul 28, 2026 at 18:41 UTC 7f4c52f2ba38a77d5341fb90a31c2cedfc0066b8
6 files changed +989 -5
.github/workflows/new_pr_bot.yml
+2 -2
@@ -80,9 +80,9 @@ jobs:
80 '',
81 '\u{1f4ac} Join our developer Slack at https://slack.vllm.ai to discuss your PR in `#pr-reviews`, coordinate on features in `#feat-` channels, or join special interest groups in `#sig-` channels.',
82 '',
83 - 'PRs do not trigger a full CI run by default. Once the PR is approved and ready to go, your PR reviewer(s) can run CI to test the changes comprehensively before merging.',
83 + 'PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment `/ci run` whenever CI signals are needed.',
84 '',
85 - 'To run CI, PR reviewers can either: Add `ready` label to the PR or enable auto-merge.',
85 + 'Once the PR is approved or has the `ready` label, the PR author can also use `/ci run` or `/ci retry`. New commits do not start CI automatically.',
86 '',
87 'If you have any questions, please reach out to us on Slack at https://slack.vllm.ai.',
88 '',
.github/workflows/pre-commit.yml
+1 -1
@@ -41,7 +41,7 @@ jobs:
41 if (hasReadyLabel || hasVerifiedLabel || mergedCount >= 4) {
42 core.info(`Check passed: verified label=${hasVerifiedLabel}, ready label=${hasReadyLabel}, 4+ merged PRs=${mergedCount >= 4}`);
43 } else {
44 - core.setFailed(`PR must have the 'verified', 'ready', or 'ready-run-all-tests' label (the ready labels also trigger tests) or the author must have at least 4 merged PRs (found ${mergedCount}).`);
44 + core.setFailed(`PR must have the 'verified', 'ready', or 'ready-run-all-tests' label to run pre-commit, or the author must have at least 4 merged PRs (found ${mergedCount}).`);
45 }
46
47 pre-commit:
.github/workflows/run-ci-command.yml new
+40
@@ -0,0 +1,40 @@
1 +name: Run CI from PR comment
2 +
3 +on:
4 + issue_comment:
5 + types: [created]
6 +
7 +concurrency:
8 + group: run-ci-comment-${{ github.event.issue.number }}
9 + cancel-in-progress: false
10 +
11 +permissions:
12 + contents: read
13 + issues: write
14 + pull-requests: read
15 +
16 +jobs:
17 + run-ci-command:
18 + if: >-
19 + github.event.issue.pull_request &&
20 + (github.event.comment.body == '/ci run' ||
21 + github.event.comment.body == '/ci retry')
22 + runs-on: ubuntu-latest
23 + steps:
24 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
25 + with:
26 + ref: ${{ github.event.repository.default_branch }}
27 + persist-credentials: false
28 + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
29 + with:
30 + python-version: "3.12"
31 + - name: Authorize and run CI command
32 + run: >-
33 + uv run --no-project --python 3.12
34 + .github/workflows/scripts/run_ci_command.py
35 + env:
36 + BUILDKITE_API_TOKEN: ${{ secrets.BUILDKITE_API_TOKEN }}
37 + BUILDKITE_ORGANIZATION: vllm
38 + BUILDKITE_PIPELINE: ci
39 + CI_TRUSTED_USERS: ${{ vars.CI_TRUSTED_USERS }}
40 + GH_TOKEN: ${{ github.token }}
.github/workflows/scripts/run_ci_command.py new
+580
@@ -0,0 +1,580 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import json
5 +import os
6 +import sys
7 +import urllib.error
8 +import urllib.parse
9 +import urllib.request
10 +from collections.abc import Mapping, Sequence
11 +from typing import Any
12 +
13 +COMMAND_RUN_CI = "/ci run"
14 +COMMAND_RETRY_FAILED = "/ci retry"
15 +READY_LABELS = {"ready", "ready-run-all-tests"}
16 +TRUSTED_PERMISSIONS = {"admin", "maintain", "write"}
17 +ACTIVE_BUILD_STATES = {
18 + "blocked",
19 + "creating",
20 + "scheduled",
21 + "running",
22 + "failing",
23 + "canceling",
24 + "waiting",
25 + "waiting_failed",
26 +}
27 +RETRY_STATES = "failed,timed_out,expired"
28 +
29 +
30 +class ApiError(RuntimeError):
31 + def __init__(self, status: int | None, message: str) -> None:
32 + super().__init__(message)
33 + self.status = status
34 +
35 +
36 +class HttpTransport:
37 + def request(
38 + self,
39 + url: str,
40 + *,
41 + body: Mapping[str, Any] | None = None,
42 + headers: Mapping[str, str] | None = None,
43 + method: str = "GET",
44 + ) -> Any:
45 + data = None if body is None else json.dumps(body).encode()
46 + request = urllib.request.Request(
47 + url,
48 + data=data,
49 + headers=dict(headers or {}),
50 + method=method,
51 + )
52 + try:
53 + with urllib.request.urlopen(request, timeout=30) as response:
54 + response_body = response.read().decode()
55 + except urllib.error.HTTPError as error:
56 + response_body = error.read().decode()
57 + message = self._error_message(response_body, error.reason)
58 + raise ApiError(
59 + error.code,
60 + f"API returned {error.code}: {message}",
61 + ) from error
62 + except urllib.error.URLError as error:
63 + raise ApiError(None, f"API request failed: {error.reason}") from error
64 +
65 + if not response_body:
66 + return None
67 + try:
68 + return json.loads(response_body)
69 + except json.JSONDecodeError as error:
70 + raise ApiError(None, "API returned a non-JSON response.") from error
71 +
72 + @staticmethod
73 + def _error_message(response_body: str, fallback: str) -> str:
74 + try:
75 + parsed = json.loads(response_body)
76 + except json.JSONDecodeError:
77 + return fallback
78 + return str(parsed.get("message", fallback))
79 +
80 +
81 +class GitHubClient:
82 + def __init__(
83 + self,
84 + token: str,
85 + repository: str,
86 + transport: HttpTransport | None = None,
87 + ) -> None:
88 + if not token:
89 + raise RuntimeError("GH_TOKEN is not set.")
90 + self.owner, self.repo = repository.split("/", maxsplit=1)
91 + self.transport = transport or HttpTransport()
92 + self.headers = {
93 + "Accept": "application/vnd.github+json",
94 + "Authorization": f"Bearer {token}",
95 + "Content-Type": "application/json",
96 + "User-Agent": "vllm-ci-command",
97 + "X-GitHub-Api-Version": "2022-11-28",
98 + }
99 +
100 + def _request(
101 + self,
102 + path: str,
103 + *,
104 + body: Mapping[str, Any] | None = None,
105 + method: str = "GET",
106 + ) -> Any:
107 + return self.transport.request(
108 + f"https://api.github.com{path}",
109 + body=body,
110 + headers=self.headers,
111 + method=method,
112 + )
113 +
114 + def _repo_path(self, suffix: str) -> str:
115 + owner = urllib.parse.quote(self.owner, safe="")
116 + repo = urllib.parse.quote(self.repo, safe="")
117 + return f"/repos/{owner}/{repo}{suffix}"
118 +
119 + def _paginate(self, path: str) -> list[dict[str, Any]]:
120 + results: list[dict[str, Any]] = []
121 + separator = "&" if "?" in path else "?"
122 + for page in range(1, 101):
123 + response = self._request(f"{path}{separator}per_page=100&page={page}")
124 + if not isinstance(response, list):
125 + raise ApiError(None, "GitHub API returned an invalid list response.")
126 + results.extend(response)
127 + if len(response) < 100:
128 + return results
129 + raise ApiError(None, "GitHub API pagination exceeded 10,000 results.")
130 +
131 + def get_pr(self, number: int) -> dict[str, Any]:
132 + return self._request(self._repo_path(f"/pulls/{number}"))
133 +
134 + def get_permission(self, actor: str) -> str:
135 + username = urllib.parse.quote(actor, safe="")
136 + try:
137 + response = self._request(
138 + self._repo_path(f"/collaborators/{username}/permission")
139 + )
140 + except ApiError as error:
141 + if error.status == 404:
142 + return "none"
143 + raise
144 + return str(response["permission"])
145 +
146 + def get_review_decision(self, number: int) -> str | None:
147 + query = """
148 + query($owner: String!, $repo: String!, $number: Int!) {
149 + repository(owner: $owner, name: $repo) {
150 + pullRequest(number: $number) {
151 + reviewDecision
152 + }
153 + }
154 + }
155 + """
156 + response = self._request(
157 + "/graphql",
158 + body={
159 + "query": query,
160 + "variables": {
161 + "number": number,
162 + "owner": self.owner,
163 + "repo": self.repo,
164 + },
165 + },
166 + method="POST",
167 + )
168 + return response["data"]["repository"]["pullRequest"]["reviewDecision"]
169 +
170 + def list_reviews(self, number: int) -> list[dict[str, Any]]:
171 + return self._paginate(self._repo_path(f"/pulls/{number}/reviews"))
172 +
173 + def list_reactions(self, comment_id: int) -> list[dict[str, Any]]:
174 + return self._paginate(
175 + self._repo_path(f"/issues/comments/{comment_id}/reactions")
176 + )
177 +
178 + def add_reaction(self, comment_id: int, content: str) -> None:
179 + self._request(
180 + self._repo_path(f"/issues/comments/{comment_id}/reactions"),
181 + body={"content": content},
182 + method="POST",
183 + )
184 +
185 + def add_comment(self, issue_number: int, body: str) -> None:
186 + self._request(
187 + self._repo_path(f"/issues/{issue_number}/comments"),
188 + body={"body": body},
189 + method="POST",
190 + )
191 +
192 +
193 +class BuildkiteClient:
194 + def __init__(
195 + self,
196 + token: str,
197 + organization: str,
198 + pipeline: str,
199 + transport: HttpTransport | None = None,
200 + ) -> None:
201 + self.token = token
202 + self.transport = transport or HttpTransport()
203 + organization = urllib.parse.quote(organization, safe="")
204 + pipeline = urllib.parse.quote(pipeline, safe="")
205 + self.base_url = (
206 + "https://api.buildkite.com/v2/organizations/"
207 + f"{organization}/pipelines/{pipeline}/builds"
208 + )
209 +
210 + def _request(
211 + self,
212 + *,
213 + body: Mapping[str, Any] | None = None,
214 + method: str = "GET",
215 + path: str = "",
216 + query: Sequence[tuple[str, str]] = (),
217 + ) -> Any:
218 + if not self.token:
219 + raise RuntimeError("The BUILDKITE_API_TOKEN repository secret is not set.")
220 + url = f"{self.base_url}{path}"
221 + if query:
222 + url = f"{url}?{urllib.parse.urlencode(query)}"
223 + return self.transport.request(
224 + url,
225 + body=body,
226 + headers={
227 + "Authorization": f"Bearer {self.token}",
228 + "Content-Type": "application/json",
229 + "User-Agent": "vllm-ci-command",
230 + },
231 + method=method,
232 + )
233 +
234 + def list_builds(
235 + self,
236 + commit: str,
237 + *,
238 + metadata: tuple[str, str] | None = None,
239 + ) -> list[dict[str, Any]]:
240 + query = [
241 + ("commit", commit),
242 + ("exclude_jobs", "true"),
243 + ("exclude_pipeline", "true"),
244 + ("per_page", "100"),
245 + ]
246 + if metadata:
247 + key, value = metadata
248 + query.append((f"meta_data[{key}]", value))
249 + response = self._request(query=query)
250 + if not isinstance(response, list):
251 + raise ApiError(None, "Buildkite API returned an invalid build list.")
252 + return response
253 +
254 + def create_build(self, body: Mapping[str, Any]) -> dict[str, Any]:
255 + return self._request(body=body, method="POST")
256 +
257 + def retry_failed_jobs(
258 + self,
259 + build_number: int,
260 + states: str,
261 + ) -> dict[str, Any]:
262 + number = urllib.parse.quote(str(build_number), safe="")
263 + return self._request(
264 + body={"states": states},
265 + method="PUT",
266 + path=f"/{number}/retry_failed_jobs",
267 + )
268 +
269 +
270 +def parse_command(body: str) -> str | None:
271 + if body in {COMMAND_RUN_CI, COMMAND_RETRY_FAILED}:
272 + return body
273 + return None
274 +
275 +
276 +def parse_trusted_users(value: str = "") -> set[str]:
277 + return {
278 + user.casefold() for item in value.split(",") for user in item.split() if user
279 + }
280 +
281 +
282 +def has_ready_label(pr: Mapping[str, Any]) -> bool:
283 + return any(label["name"] in READY_LABELS for label in pr["labels"])
284 +
285 +
286 +def is_trusted_permission(permission: str) -> bool:
287 + return permission in TRUSTED_PERMISSIONS
288 +
289 +
290 +def authorize(
291 + *,
292 + actor: str,
293 + permission: str,
294 + pr: Mapping[str, Any],
295 + trusted_approval: bool = False,
296 + trusted_users: set[str] | None = None,
297 +) -> tuple[bool, str]:
298 + trusted_users = trusted_users or set()
299 + if is_trusted_permission(permission):
300 + return True, f"repository {permission} permission"
301 + if actor.casefold() in trusted_users:
302 + return True, "configured trusted contributor"
303 + if actor.casefold() != pr["user"]["login"].casefold():
304 + return (
305 + False,
306 + "Only reviewers with write access can run CI before it is "
307 + "delegated to the PR author.",
308 + )
309 + if pr["draft"]:
310 + return False, "PR authors cannot run CI while the PR is a draft."
311 + if has_ready_label(pr):
312 + return True, "ready label"
313 + if trusted_approval:
314 + return True, "approval from a trusted reviewer"
315 + return (
316 + False,
317 + "A reviewer with write access must run `/ci run`, approve the PR, "
318 + "or add the `ready` label first.",
319 + )
320 +
321 +
322 +def has_trusted_approval(
323 + github: GitHubClient,
324 + number: int,
325 + trusted_users: set[str],
326 +) -> bool:
327 + if github.get_review_decision(number) != "APPROVED":
328 + return False
329 +
330 + latest_review_states: dict[str, tuple[str, str]] = {}
331 + for review in github.list_reviews(number):
332 + user = review.get("user") or {}
333 + login = user.get("login")
334 + state = review.get("state")
335 + if login and state in {"APPROVED", "CHANGES_REQUESTED", "DISMISSED"}:
336 + latest_review_states[login.casefold()] = (login, state)
337 +
338 + for login, state in latest_review_states.values():
339 + if state != "APPROVED":
340 + continue
341 + if login.casefold() in trusted_users:
342 + return True
343 + if is_trusted_permission(github.get_permission(login)):
344 + return True
345 + return False
346 +
347 +
348 +def is_build_for_pr(build: Mapping[str, Any], pr_number: int) -> bool:
349 + pull_request = build.get("pull_request")
350 + if isinstance(pull_request, Mapping):
351 + build_pr_number = pull_request.get("id", pull_request.get("number"))
352 + if build_pr_number is not None:
353 + return str(build_pr_number) == str(pr_number)
354 + metadata = build.get("meta_data") or {}
355 + return str(metadata.get("github-pr-number")) == str(pr_number)
356 +
357 +
358 +def is_active_build(build: Mapping[str, Any]) -> bool:
359 + return bool(build.get("blocked")) or build.get("state") in ACTIVE_BUILD_STATES
360 +
361 +
362 +def select_latest_build(
363 + builds: Sequence[dict[str, Any]],
364 + pr_number: int,
365 +) -> dict[str, Any] | None:
366 + matching = [build for build in builds if is_build_for_pr(build, pr_number)]
367 + return max(matching, key=lambda build: build.get("created_at", ""), default=None)
368 +
369 +
370 +def create_build_payload(
371 + *,
372 + actor: str,
373 + comment_id: int,
374 + pr: Mapping[str, Any],
375 +) -> dict[str, Any]:
376 + return {
377 + "commit": pr["head"]["sha"],
378 + "branch": pr["head"]["ref"],
379 + "message": f"PR #{pr['number']} {COMMAND_RUN_CI} by @{actor}",
380 + "pull_request_id": pr["number"],
381 + "pull_request_base_branch": pr["base"]["ref"],
382 + "pull_request_repository": pr["head"]["repo"]["clone_url"],
383 + "pull_request_labels": [label["name"] for label in pr["labels"]],
384 + "env": {
385 + "VLLM_CI_GITHUB_COMMENT_ID": str(comment_id),
386 + "VLLM_CI_TRIGGERED_BY": actor,
387 + },
388 + "meta_data": {
389 + "github-comment-id": str(comment_id),
390 + "github-pr-number": str(pr["number"]),
391 + "github-triggered-by": actor,
392 + },
393 + }
394 +
395 +
396 +def add_reaction_safely(
397 + github: GitHubClient,
398 + comment_id: int,
399 + content: str,
400 +) -> None:
401 + try:
402 + github.add_reaction(comment_id, content)
403 + except Exception as error:
404 + print(f"Could not add {content} reaction: {error}", file=sys.stderr)
405 +
406 +
407 +def is_already_handled(github: GitHubClient, comment_id: int) -> bool:
408 + return any(
409 + reaction.get("content") in {"rocket", "-1"}
410 + and (reaction.get("user") or {}).get("login") == "github-actions[bot]"
411 + for reaction in github.list_reactions(comment_id)
412 + )
413 +
414 +
415 +def handle_run_ci(
416 + *,
417 + actor: str,
418 + buildkite: BuildkiteClient,
419 + comment_id: int,
420 + github: GitHubClient,
421 + pr: Mapping[str, Any],
422 +) -> str:
423 + duplicate_builds = buildkite.list_builds(
424 + pr["head"]["sha"],
425 + metadata=("github-comment-id", str(comment_id)),
426 + )
427 + duplicate = select_latest_build(duplicate_builds, pr["number"])
428 + if duplicate:
429 + return f"CI was already requested by this comment: {duplicate['web_url']}"
430 +
431 + current_builds = buildkite.list_builds(pr["head"]["sha"])
432 + active_build = next(
433 + (
434 + build
435 + for build in current_builds
436 + if is_build_for_pr(build, pr["number"]) and is_active_build(build)
437 + ),
438 + None,
439 + )
440 + if active_build:
441 + return f"CI is already running for this commit: {active_build['web_url']}"
442 +
443 + current_pr = github.get_pr(pr["number"])
444 + if current_pr["state"] != "open" or current_pr["head"]["sha"] != pr["head"]["sha"]:
445 + return (
446 + "The PR head changed while processing the command. Comment `/ci run` again."
447 + )
448 +
449 + build = buildkite.create_build(
450 + create_build_payload(
451 + actor=actor,
452 + comment_id=comment_id,
453 + pr=current_pr,
454 + )
455 + )
456 + return (
457 + f"Triggered [Buildkite CI #{build['number']}]({build['web_url']}) "
458 + f"for commit `{current_pr['head']['sha'][:12]}`."
459 + )
460 +
461 +
462 +def handle_retry_failed(
463 + *,
464 + buildkite: BuildkiteClient,
465 + pr: Mapping[str, Any],
466 +) -> str:
467 + builds = buildkite.list_builds(pr["head"]["sha"])
468 + build = select_latest_build(builds, pr["number"])
469 + if not build:
470 + return "No CI build exists for the current PR commit. Use `/ci run` first."
471 + if not build.get("finished_at") or is_active_build(build):
472 + return f"CI is still running for this commit: {build['web_url']}"
473 +
474 + retried = buildkite.retry_failed_jobs(build["number"], RETRY_STATES)
475 + if retried["retried_jobs_count"] == 0:
476 + return (
477 + f"No failed, timed-out, or expired jobs need retrying: {build['web_url']}"
478 + )
479 + return (
480 + f"Queued {retried['retried_jobs_count']} failed job(s) for retry in "
481 + f"[Buildkite CI #{build['number']}]({build['web_url']})."
482 + )
483 +
484 +
485 +def run(
486 + event: Mapping[str, Any],
487 + github: GitHubClient,
488 + buildkite: BuildkiteClient,
489 + trusted_users_value: str = "",
490 +) -> None:
491 + command = parse_command(event["comment"]["body"])
492 + if not command or "pull_request" not in event["issue"]:
493 + return
494 +
495 + issue_number = event["issue"]["number"]
496 + comment_id = event["comment"]["id"]
497 + actor = event["comment"]["user"]["login"]
498 +
499 + if is_already_handled(github, comment_id):
500 + print(f"Comment {comment_id} was already handled.")
501 + return
502 + add_reaction_safely(github, comment_id, "eyes")
503 +
504 + try:
505 + pr = github.get_pr(issue_number)
506 + permission = github.get_permission(actor)
507 + if pr["state"] != "open":
508 + github.add_comment(issue_number, "CI commands require an open PR.")
509 + return
510 +
511 + trusted_users = parse_trusted_users(trusted_users_value)
512 + should_check_approval = (
513 + not is_trusted_permission(permission)
514 + and actor.casefold() not in trusted_users
515 + and actor.casefold() == pr["user"]["login"].casefold()
516 + and not pr["draft"]
517 + and not has_ready_label(pr)
518 + )
519 + trusted_approval = should_check_approval and has_trusted_approval(
520 + github,
521 + issue_number,
522 + trusted_users,
523 + )
524 + allowed, reason = authorize(
525 + actor=actor,
526 + permission=permission,
527 + pr=pr,
528 + trusted_approval=trusted_approval,
529 + trusted_users=trusted_users,
530 + )
531 + if not allowed:
532 + add_reaction_safely(github, comment_id, "-1")
533 + github.add_comment(issue_number, f"@{actor}, {reason}")
534 + return
535 +
536 + print(f"Authorized @{actor}: {reason}")
537 + if command == COMMAND_RUN_CI:
538 + message = handle_run_ci(
539 + actor=actor,
540 + buildkite=buildkite,
541 + comment_id=comment_id,
542 + github=github,
543 + pr=pr,
544 + )
545 + else:
546 + message = handle_retry_failed(buildkite=buildkite, pr=pr)
547 + add_reaction_safely(github, comment_id, "rocket")
548 + github.add_comment(issue_number, message)
549 + except Exception:
550 + add_reaction_safely(github, comment_id, "confused")
551 + raise
552 +
553 +
554 +def main() -> None:
555 + event_path = os.environ["GITHUB_EVENT_PATH"]
556 + with open(event_path, encoding="utf-8") as event_file:
557 + event = json.load(event_file)
558 +
559 + if not parse_command(event["comment"]["body"]):
560 + return
561 +
562 + github = GitHubClient(
563 + os.environ.get("GH_TOKEN", ""),
564 + os.environ["GITHUB_REPOSITORY"],
565 + )
566 + buildkite = BuildkiteClient(
567 + os.environ.get("BUILDKITE_API_TOKEN", ""),
568 + os.environ.get("BUILDKITE_ORGANIZATION", "vllm"),
569 + os.environ.get("BUILDKITE_PIPELINE", "ci"),
570 + )
571 + run(
572 + event,
573 + github,
574 + buildkite,
575 + os.environ.get("CI_TRUSTED_USERS", ""),
576 + )
577 +
578 +
579 +if __name__ == "__main__":
580 + main()
.github/workflows/scripts/test_run_ci_command.py new
+362
@@ -0,0 +1,362 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import unittest
5 +from typing import Any
6 +
7 +from run_ci_command import (
8 + COMMAND_RETRY_FAILED,
9 + COMMAND_RUN_CI,
10 + RETRY_STATES,
11 + BuildkiteClient,
12 + authorize,
13 + create_build_payload,
14 + has_trusted_approval,
15 + is_active_build,
16 + is_build_for_pr,
17 + parse_command,
18 + parse_trusted_users,
19 + run,
20 + select_latest_build,
21 +)
22 +
23 +
24 +def make_pr(**overrides: Any) -> dict[str, Any]:
25 + pr = {
26 + "base": {"ref": "main"},
27 + "draft": False,
28 + "head": {
29 + "ref": "feature",
30 + "repo": {"clone_url": "https://github.com/contributor/vllm.git"},
31 + "sha": "0123456789abcdef",
32 + },
33 + "labels": [],
34 + "number": 42,
35 + "state": "open",
36 + "user": {"login": "author"},
37 + }
38 + pr.update(overrides)
39 + return pr
40 +
41 +
42 +def make_event(command: str, actor: str = "reviewer") -> dict[str, Any]:
43 + return {
44 + "comment": {
45 + "body": command,
46 + "id": 99,
47 + "user": {"login": actor},
48 + },
49 + "issue": {
50 + "number": 42,
51 + "pull_request": {},
52 + },
53 + }
54 +
55 +
56 +class FakeGitHub:
57 + def __init__(
58 + self,
59 + *,
60 + permission: str = "write",
61 + permissions: dict[str, str] | None = None,
62 + pr: dict[str, Any] | None = None,
63 + review_decision: str = "REVIEW_REQUIRED",
64 + reviews: list[dict[str, Any]] | None = None,
65 + ) -> None:
66 + self.comments: list[str] = []
67 + self.permission = permission
68 + self.permissions = permissions or {}
69 + self.pr = pr or make_pr()
70 + self.reactions: list[str] = []
71 + self.review_decision = review_decision
72 + self.reviews = reviews or []
73 +
74 + def get_pr(self, number: int) -> dict[str, Any]:
75 + return self.pr
76 +
77 + def get_permission(self, actor: str) -> str:
78 + return self.permissions.get(actor, self.permission)
79 +
80 + def get_review_decision(self, number: int) -> str:
81 + return self.review_decision
82 +
83 + def list_reviews(self, number: int) -> list[dict[str, Any]]:
84 + return self.reviews
85 +
86 + def list_reactions(self, comment_id: int) -> list[dict[str, Any]]:
87 + return []
88 +
89 + def add_reaction(self, comment_id: int, content: str) -> None:
90 + self.reactions.append(content)
91 +
92 + def add_comment(self, issue_number: int, body: str) -> None:
93 + self.comments.append(body)
94 +
95 +
96 +class FakeBuildkite:
97 + def __init__(
98 + self,
99 + build_lists: list[list[dict[str, Any]]] | None = None,
100 + ) -> None:
101 + self.build_lists = build_lists or []
102 + self.created_builds: list[dict[str, Any]] = []
103 + self.list_calls: list[tuple[str, tuple[str, str] | None]] = []
104 + self.retry_calls: list[tuple[int, str]] = []
105 +
106 + def list_builds(
107 + self,
108 + commit: str,
109 + *,
110 + metadata: tuple[str, str] | None = None,
111 + ) -> list[dict[str, Any]]:
112 + self.list_calls.append((commit, metadata))
113 + return self.build_lists.pop(0)
114 +
115 + def create_build(self, body: dict[str, Any]) -> dict[str, Any]:
116 + self.created_builds.append(body)
117 + return {
118 + "number": 123,
119 + "web_url": "https://buildkite.example/builds/123",
120 + }
121 +
122 + def retry_failed_jobs(
123 + self,
124 + build_number: int,
125 + states: str,
126 + ) -> dict[str, Any]:
127 + self.retry_calls.append((build_number, states))
128 + return {"retried_jobs_count": 3}
129 +
130 +
131 +class FakeTransport:
132 + def __init__(self, response: Any) -> None:
133 + self.calls: list[dict[str, Any]] = []
134 + self.response = response
135 +
136 + def request(self, url: str, **kwargs: Any) -> Any:
137 + self.calls.append({"url": url, **kwargs})
138 + return self.response
139 +
140 +
141 +class RunCiCommandTest(unittest.TestCase):
142 + def test_only_exact_ci_commands_are_accepted(self) -> None:
143 + self.assertEqual(parse_command(COMMAND_RUN_CI), COMMAND_RUN_CI)
144 + self.assertEqual(
145 + parse_command(COMMAND_RETRY_FAILED),
146 + COMMAND_RETRY_FAILED,
147 + )
148 + self.assertIsNone(parse_command("/ci run please"))
149 + self.assertIsNone(parse_command(" /ci run"))
150 +
151 + def test_write_access_authorizes_reviewers_and_authors(self) -> None:
152 + allowed, _ = authorize(
153 + actor="reviewer",
154 + permission="write",
155 + pr=make_pr(),
156 + )
157 + self.assertTrue(allowed)
158 +
159 + def test_configured_trusted_contributors_can_run_ci(self) -> None:
160 + trusted_users = parse_trusted_users("trusted-one, TRUSTED-TWO")
161 + allowed, _ = authorize(
162 + actor="trusted-two",
163 + permission="read",
164 + pr=make_pr(),
165 + trusted_users=trusted_users,
166 + )
167 + self.assertTrue(allowed)
168 +
169 + def test_authors_need_an_approval_or_ready_label(self) -> None:
170 + pending, _ = authorize(
171 + actor="author",
172 + permission="read",
173 + pr=make_pr(),
174 + )
175 + approved, _ = authorize(
176 + actor="author",
177 + permission="read",
178 + pr=make_pr(),
179 + trusted_approval=True,
180 + )
181 + ready, _ = authorize(
182 + actor="author",
183 + permission="read",
184 + pr=make_pr(labels=[{"name": "ready"}]),
185 + )
186 + self.assertFalse(pending)
187 + self.assertTrue(approved)
188 + self.assertTrue(ready)
189 +
190 + def test_non_author_contributors_without_write_are_denied(self) -> None:
191 + allowed, _ = authorize(
192 + actor="contributor",
193 + permission="read",
194 + pr=make_pr(),
195 + trusted_approval=True,
196 + )
197 + self.assertFalse(allowed)
198 +
199 + def test_authors_cannot_use_ready_state_on_draft_prs(self) -> None:
200 + allowed, _ = authorize(
201 + actor="author",
202 + permission="read",
203 + pr=make_pr(draft=True, labels=[{"name": "ready"}]),
204 + trusted_approval=True,
205 + )
206 + self.assertFalse(allowed)
207 +
208 + def test_only_trusted_reviewers_can_delegate_through_approval(self) -> None:
209 + approved_review = {
210 + "state": "APPROVED",
211 + "user": {"login": "reviewer"},
212 + }
213 + trusted = FakeGitHub(
214 + permission="read",
215 + permissions={"reviewer": "write"},
216 + review_decision="APPROVED",
217 + reviews=[approved_review],
218 + )
219 + untrusted = FakeGitHub(
220 + permission="read",
221 + review_decision="APPROVED",
222 + reviews=[approved_review],
223 + )
224 + self.assertTrue(has_trusted_approval(trusted, 42, set()))
225 + self.assertFalse(has_trusted_approval(untrusted, 42, set()))
226 +
227 + def test_build_matching_is_scoped_to_the_pr(self) -> None:
228 + self.assertTrue(is_build_for_pr({"pull_request": {"id": 42}}, 42))
229 + self.assertFalse(is_build_for_pr({"pull_request": {"id": 43}}, 42))
230 + self.assertTrue(
231 + is_build_for_pr(
232 + {"meta_data": {"github-pr-number": "42"}},
233 + 42,
234 + )
235 + )
236 +
237 + def test_latest_build_selection_ignores_other_prs(self) -> None:
238 + latest = select_latest_build(
239 + [
240 + {
241 + "created_at": "2026-07-28T02:00:00Z",
242 + "number": 3,
243 + "pull_request": {"id": 43},
244 + },
245 + {
246 + "created_at": "2026-07-28T01:00:00Z",
247 + "number": 2,
248 + "pull_request": {"id": 42},
249 + },
250 + {
251 + "created_at": "2026-07-28T00:00:00Z",
252 + "number": 1,
253 + "pull_request": {"id": 42},
254 + },
255 + ],
256 + 42,
257 + )
258 + self.assertEqual(latest["number"], 2)
259 +
260 + def test_active_build_states_prevent_duplicate_runs(self) -> None:
261 + self.assertTrue(is_active_build({"state": "scheduled"}))
262 + self.assertTrue(is_active_build({"state": "running"}))
263 + self.assertTrue(is_active_build({"state": "waiting"}))
264 + self.assertTrue(is_active_build({"blocked": True, "state": "passed"}))
265 + self.assertFalse(is_active_build({"state": "failed"}))
266 +
267 + def test_build_payload_preserves_pr_context(self) -> None:
268 + payload = create_build_payload(
269 + actor="reviewer",
270 + comment_id=99,
271 + pr=make_pr(labels=[{"name": "ready"}, {"name": "v1"}]),
272 + )
273 + self.assertEqual(
274 + payload,
275 + {
276 + "commit": "0123456789abcdef",
277 + "branch": "feature",
278 + "message": "PR #42 /ci run by @reviewer",
279 + "pull_request_id": 42,
280 + "pull_request_base_branch": "main",
281 + "pull_request_repository": ("https://github.com/contributor/vllm.git"),
282 + "pull_request_labels": ["ready", "v1"],
283 + "env": {
284 + "VLLM_CI_GITHUB_COMMENT_ID": "99",
285 + "VLLM_CI_TRIGGERED_BY": "reviewer",
286 + },
287 + "meta_data": {
288 + "github-comment-id": "99",
289 + "github-pr-number": "42",
290 + "github-triggered-by": "reviewer",
291 + },
292 + },
293 + )
294 +
295 + def test_ci_run_dispatches_build_with_current_pr_metadata(self) -> None:
296 + github = FakeGitHub()
297 + buildkite = FakeBuildkite([[], []])
298 + run(make_event(COMMAND_RUN_CI), github, buildkite)
299 +
300 + self.assertEqual(len(buildkite.created_builds), 1)
301 + self.assertEqual(
302 + buildkite.created_builds[0]["message"],
303 + "PR #42 /ci run by @reviewer",
304 + )
305 + self.assertEqual(github.reactions, ["eyes", "rocket"])
306 + self.assertIn("Buildkite CI #123", github.comments[0])
307 +
308 + def test_unapproved_authors_are_denied_without_buildkite(self) -> None:
309 + github = FakeGitHub(
310 + permission="read",
311 + pr=make_pr(),
312 + review_decision="REVIEW_REQUIRED",
313 + )
314 + buildkite = FakeBuildkite()
315 + run(make_event(COMMAND_RUN_CI, "author"), github, buildkite)
316 +
317 + self.assertEqual(buildkite.list_calls, [])
318 + self.assertEqual(github.reactions, ["eyes", "-1"])
319 + self.assertIn("approve the PR", github.comments[0])
320 +
321 + def test_ci_retry_uses_latest_current_sha_build(self) -> None:
322 + github = FakeGitHub(
323 + permission="read",
324 + pr=make_pr(labels=[{"name": "ready"}]),
325 + )
326 + buildkite = FakeBuildkite(
327 + [
328 + [
329 + {
330 + "created_at": "2026-07-28T01:00:00Z",
331 + "finished_at": "2026-07-28T02:00:00Z",
332 + "number": 123,
333 + "pull_request": {"id": 42},
334 + "state": "failed",
335 + "web_url": "https://buildkite.example/builds/123",
336 + }
337 + ]
338 + ]
339 + )
340 + run(make_event(COMMAND_RETRY_FAILED, "author"), github, buildkite)
341 +
342 + self.assertEqual(buildkite.retry_calls, [(123, RETRY_STATES)])
343 + self.assertIn("Queued 3 failed job", github.comments[0])
344 +
345 + def test_buildkite_retry_uses_retry_failed_jobs_endpoint(self) -> None:
346 + transport = FakeTransport({"retried_jobs_count": 2})
347 + client = BuildkiteClient(
348 + "secret",
349 + "vllm",
350 + "ci",
351 + transport=transport,
352 + )
353 + client.retry_failed_jobs(123, RETRY_STATES)
354 +
355 + call = transport.calls[0]
356 + self.assertEqual(call["method"], "PUT")
357 + self.assertTrue(call["url"].endswith("/123/retry_failed_jobs"))
358 + self.assertEqual(call["body"], {"states": RETRY_STATES})
359 +
360 +
361 +if __name__ == "__main__":
362 + unittest.main()
docs/contributing/README.md
+4 -2
@@ -301,8 +301,10 @@ review process:
301 isn't clear or you disagree with a suggestion, feel free to ask for
302 clarification or discuss the suggestion.
303 - Note that not all CI checks will be executed due to limited computational
304 - resources. The reviewer will add `ready` label to the PR when the PR is
305 - ready to merge or a full CI run is needed.
304 + resources. Reviewers with write access and configured trusted contributors
305 + can comment `/ci run` when CI signals are needed before a PR is ready. After
306 + the PR is approved or has the `ready` label, the PR author can use `/ci run`
307 + or `/ci retry`. New commits do not start CI automatically.
308
309 ### Pull Request Limits and Escalation
310