Skip to content
Draft
1 change: 1 addition & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches:
- master
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
merge_group:
types: [checks_requested]

Expand Down
4 changes: 3 additions & 1 deletion ddev/src/ddev/cli/validate/all/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ def all(
If TARGET is provided (e.g. 'changed'), per-integration validations are
scoped to that target. Repo-wide validations always run without a target.
"""
from ddev.cli.validate.all.github import get_pr_number, write_step_summary
from ddev.cli.validate.all.github import get_pr_number, should_suppress_validation_comments, write_step_summary
from ddev.cli.validate.all.orchestrator import ValidationOrchestrator

selected = _load_validations(app)
Expand All @@ -69,12 +69,14 @@ def all(
app.abort()

pr_number = get_pr_number(app)
suppress_pr_comments = should_suppress_validation_comments()
orchestrator = ValidationOrchestrator(
app=app,
target=target,
validations=list(selected),
fix=fix,
pr_number=pr_number,
suppress_pr_comments=suppress_pr_comments,
grace_period=grace_period,
max_timeout=max_timeout,
subprocess_timeout=subprocess_timeout,
Expand Down
42 changes: 40 additions & 2 deletions ddev/src/ddev/cli/validate/all/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
from ddev.cli.validate.all.orchestrator import ValidationConfig, ValidationResult

COMMENT_HEADING = "## Validation Report"
COMMENT_STATUS_SUCCESS = "<!-- ddev-validation-report:success -->"
COMMENT_STATUS_ACTION_REQUIRED = "<!-- ddev-validation-report:action-required -->"
# Suppresses all validation PR comments, including failures, on the next validation run.
VALIDATION_COMMENT_SUPPRESSION_LABEL = "ci/skip-validation-comments"


def parse_pr_number_from_event(event_path: str) -> int | None:
Expand Down Expand Up @@ -59,6 +63,32 @@ def get_pr_number(app: Application) -> int | None:
return None


def pr_has_label_from_event(event_path: str, label: str) -> bool:
"""Return whether the GitHub Actions PR event payload contains a label."""
try:
event = json.loads(Path(event_path).read_text())
except (json.JSONDecodeError, OSError):
return False

pr = event.get("pull_request")
if not isinstance(pr, dict):
return False

labels = pr.get("labels", [])
if not isinstance(labels, list):
return False

return any(isinstance(item, dict) and item.get("name") == label for item in labels)


def should_suppress_validation_comments() -> bool:
if os.environ.get("GITHUB_EVENT_NAME") != "pull_request":
return False
if event_path := os.environ.get("GITHUB_EVENT_PATH"):
return pr_has_label_from_event(event_path, VALIDATION_COMMENT_SUPPRESSION_LABEL)
Comment thread
nubtron marked this conversation as resolved.
return False


def get_workflow_run_url() -> str | None:
server = os.environ.get("GITHUB_SERVER_URL")
repo = os.environ.get("GITHUB_REPOSITORY")
Expand All @@ -75,8 +105,14 @@ def write_step_summary(content: str) -> None:
f.write(content + "\n")


def _build_preamble(error: str | None, warning: str | None) -> list[str]:
def is_successful_validation_comment(body: str) -> bool:
return COMMENT_STATUS_SUCCESS in body


def _build_preamble(error: str | None, warning: str | None, status_marker: str | None = None) -> list[str]:
parts: list[str] = [f"{COMMENT_HEADING}\n"]
if status_marker:
parts.append(f"{status_marker}\n")
if error:
parts.append(f"> **Error:** {error}\n")
if warning:
Expand Down Expand Up @@ -136,6 +172,7 @@ def format_pr_comment(
*,
error: str | None = None,
warning: str | None = None,
successful: bool = False,
) -> str:
"""Format a PR comment with collapsible sections to reduce clutter."""
failures: dict[str, ValidationResult] = {}
Expand All @@ -144,7 +181,8 @@ def format_pr_comment(
(passed if result.success else failures)[name] = result

incomplete = _build_incomplete_warning(expected_validations, results)
parts = _build_preamble(error, warning)
status_marker = COMMENT_STATUS_SUCCESS if successful else COMMENT_STATUS_ACTION_REQUIRED
parts = _build_preamble(error, warning, status_marker)
parts.extend(incomplete)

if failures:
Expand Down
111 changes: 85 additions & 26 deletions ddev/src/ddev/cli/validate/all/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any, TypedDict

from ddev.cli.validate.all.github import (
COMMENT_HEADING,
format_pr_comment,
format_step_summary,
get_workflow_run_url,
is_successful_validation_comment,
write_step_summary,
)
from ddev.event_bus.orchestrator import BaseMessage, EventBusOrchestrator, SyncProcessor
Expand All @@ -26,6 +27,11 @@
from ddev.cli.application import Application


class GithubComment(TypedDict):
id: int
body: str


@dataclass(frozen=True)
class ValidationConfig:
description: str = ""
Expand Down Expand Up @@ -183,6 +189,7 @@ def __init__(
validations: list[str] | None = None,
fix: bool = False,
pr_number: int | None = None,
suppress_pr_comments: bool = False,
grace_period: float = 5,
max_timeout: float = 600,
subprocess_timeout: float = SUBPROCESS_TIMEOUT,
Expand All @@ -199,6 +206,7 @@ def __init__(
self._target = target
self._fix = fix
self._pr_number = pr_number
self._suppress_pr_comments = suppress_pr_comments
self._results: dict[str, ValidationResult] = {}

self.register_processor(
Expand Down Expand Up @@ -236,38 +244,93 @@ def _build_error_and_warning(self, exception: Exception | None) -> tuple[str | N

return error_msg, extra_warning

def _delete_previous_comments(self, pr_number: int) -> None:
try:
comments = self._app.github.get_pull_request_comments(pr_number)
for comment in comments:
if comment.get("body", "").startswith(COMMENT_HEADING):
self._app.github.delete_comment(comment["id"])
except Exception as exc:
self._app.display_warning(f"Failed to clean up previous validation comments: {exc}")

def _publish_report(self, exception: Exception | None) -> None:
error_msg, extra_warning = self._build_error_and_warning(exception)

summary_body = format_step_summary(
self._results,
VALIDATIONS,
self._target,
self._validations,
error=error_msg,
warning=extra_warning,
def _current_run_succeeded(self, exception: Exception | None) -> bool:
return (
exception is None
and len(self._results) == len(self._validations)
and all(result.success for result in self._results.values())
)
write_step_summary(summary_body)

def _get_previous_validation_comments(self, pr_number: int) -> list[GithubComment]:
comments: list[dict[str, Any]] = self._app.github.get_pull_request_comments(pr_number)
return [
{"id": comment["id"], "body": comment.get("body", "")}
for comment in comments
if comment.get("body", "").startswith(COMMENT_HEADING)
]

def _delete_comments(self, comments: list[GithubComment]) -> None:
for comment in comments:
try:
self._app.github.delete_comment(comment["id"])
except Exception as exc:
self._app.display_warning(
f"Failed to delete previous validation comment {comment['id']}: {type(exc).__name__}: {exc}"
)

def _previous_success_already_reported(
self, current_succeeded: bool, previous_comments: list[GithubComment]
) -> bool:
if not current_succeeded or not previous_comments:
return False
return all(is_successful_validation_comment(comment["body"]) for comment in previous_comments)

def _build_pr_comment_body(self, error_msg: str | None, extra_warning: str | None, current_succeeded: bool) -> str:
comment_body = format_pr_comment(
self._results,
VALIDATIONS,
self._target,
self._validations,
error=error_msg,
warning=extra_warning,
successful=current_succeeded,
)
if run_url := get_workflow_run_url():
comment_body += f"\n\n[View full run]({run_url})"
return comment_body

def _fetch_previous_validation_comments_or_empty(self, pr_number: int) -> list[GithubComment]:
self._app.logger.debug("Fetching previous validation comments on PR #%s...", pr_number)
try:
return self._get_previous_validation_comments(pr_number)
except Exception as exc:
self._app.display_warning(f"Failed to read previous validation comments: {type(exc).__name__}: {exc}")
return []

def _sync_pr_comment(self, comment_body: str, current_succeeded: bool) -> None:
if self._pr_number is None:
return

previous_comments = self._fetch_previous_validation_comments_or_empty(self._pr_number)
if self._suppress_pr_comments:
self._app.logger.debug("Validation PR comments are suppressed for PR #%s.", self._pr_number)
self._delete_comments(previous_comments)
return
if self._previous_success_already_reported(current_succeeded, previous_comments):
self._app.logger.debug("Previous validation comments already reported success; skipping PR comment.")
return

self._app.logger.debug("Deleting previous validation comments on PR #%s...", self._pr_number)
self._delete_comments(previous_comments)
self._app.logger.debug("Posting validation comment on PR #%s...", self._pr_number)
self._app.github.post_pull_request_comment(self._pr_number, comment_body)
self._app.logger.debug("Comment posted successfully.")

def _publish_report(self, exception: Exception | None) -> None:
error_msg, extra_warning = self._build_error_and_warning(exception)
write_step_summary(
format_step_summary(
self._results,
VALIDATIONS,
self._target,
self._validations,
error=error_msg,
warning=extra_warning,
)
)

current_succeeded = self._current_run_succeeded(exception)
comment_body = self._build_pr_comment_body(error_msg, extra_warning, current_succeeded)

self._app.logger.debug("PR number: %s", self._pr_number)
self._app.logger.debug("GitHub token configured: %s", bool(self._app.config.github.token))
Expand All @@ -283,11 +346,7 @@ def _publish_report(self, exception: Exception | None) -> None:
previous_level = httpx_logger.level
httpx_logger.setLevel(logging.WARNING)
try:
self._app.logger.debug("Deleting previous validation comments on PR #%s...", self._pr_number)
self._delete_previous_comments(self._pr_number)
self._app.logger.debug("Posting validation comment on PR #%s...", self._pr_number)
self._app.github.post_pull_request_comment(self._pr_number, comment_body)
self._app.logger.debug("Comment posted successfully.")
self._sync_pr_comment(comment_body, current_succeeded)
except Exception as exc:
self._app.display_warning(f"Failed to post PR comment: {type(exc).__name__}: {exc}")
write_step_summary(f"\n> Failed to post PR comment: {type(exc).__name__}: {exc}")
Expand Down
34 changes: 34 additions & 0 deletions ddev/tests/cli/validate/all/test_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@

from unittest.mock import patch

import pytest

from ddev.cli.validate.all.github import VALIDATION_COMMENT_SUPPRESSION_LABEL
from ddev.cli.validate.all.orchestrator import VALIDATIONS

from .conftest import completed_process
Expand Down Expand Up @@ -117,3 +120,34 @@ def test_all_command_aborts_when_no_validations_configured(ddev):

assert result.exit_code != 0
assert NO_VALIDATIONS_ERROR in result.output


@pytest.mark.parametrize(
"label, expected",
[
pytest.param(VALIDATION_COMMENT_SUPPRESSION_LABEL, True, id="label-present"),
pytest.param("other-label", False, id="label-absent"),
],
)
def test_all_command_passes_comment_suppression_label_state(ddev, tmp_path, monkeypatch, label, expected):
event_file = tmp_path / "event.json"
event_file.write_text(f'{{"pull_request": {{"labels": [{{"name": "{label}"}}]}}}}')
monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request")
monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file))
captured: dict[str, object] = {}

class FakeOrchestrator:
def __init__(self, **kwargs):
captured.update(kwargs)

def run(self):
pass

with (
patch("ddev.cli.validate.all._load_validations", return_value={"config": VALIDATIONS["config"]}),
patch("ddev.cli.validate.all.orchestrator.ValidationOrchestrator", FakeOrchestrator),
):
result = ddev("validate", "all", *FAST_ORCHESTRATOR_OPTS)

assert result.exit_code == 0, result.output
assert captured["suppress_pr_comments"] is expected
Loading
Loading