Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/run-validations.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ on:
ddev-version:
required: false
type: string
scope:
required: false
type: string
default: all

defaults:
run:
Expand All @@ -26,6 +30,7 @@ jobs:

env:
PYTHON_VERSION: "3.13"
SCOPE: ${{ inputs.scope }}
TARGET: ${{ github.event_name == 'pull_request' && 'changed' || '' }}

steps:
Expand Down Expand Up @@ -85,4 +90,4 @@ jobs:
env:
DD_GITHUB_USER: "${{ github.actor }}"
DD_GITHUB_TOKEN: "${{ steps.octo-sts.outputs.token || secrets.GITHUB_TOKEN }}"
run: ddev validate all "$TARGET"
run: ddev validate all --scope "$SCOPE" "$TARGET"
24 changes: 24 additions & 0 deletions .github/workflows/validate-pr-metadata.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# PR metadata validations are separate so they can use their own triggers and check.
# Description edits and label changes then neither re-run nor mask the full suite.
name: Validate PR metadata

on:
pull_request:
types: [opened, synchronize, reopened, edited, labeled, unlabeled]

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true

permissions:
id-token: write
contents: read

jobs:
run:
name: Run PR Metadata Validations
uses: ./.github/workflows/run-validations.yml
with:
repo: core
scope: pr-metadata
secrets: inherit
3 changes: 2 additions & 1 deletion .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ on:
branches:
- master
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
types: [opened, synchronize, reopened]
merge_group:
types: [checks_requested]

Expand All @@ -23,4 +23,5 @@ jobs:
uses: ./.github/workflows/run-validations.yml
with:
repo: core
scope: code
secrets: inherit
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ These guidelines apply to automated code review (the Codex review bot). They do
- Open PRs in draft mode unless explicitly asked otherwise; mark them ready for review once the work is complete and CI passes.
- Always populate the PR body using the repository's template at `.github/PULL_REQUEST_TEMPLATE.md`. Read the template first and fill in every section (`What does this PR do?`, `Motivation`, `Review checklist`). Do not omit, rename, or reorder the template sections, and do not add unrelated sections on top.
- Keep PR titles short and descriptive in plain words. Do not use conventional-commit prefixes (`feat:`, `fix:`, `chore:`).
- Every PR must declare a QA decision via a label. Add `qa/required` if the PR ships changes that need QA validation, or `qa/skip-qa` if it does not (e.g., docs, tests, developer tooling, or no agent-impacting changes). The `validate-all` CI check fails until exactly one of those labels is set. Tick the matching checkbox in the PR template.
- Every PR must declare a QA decision via a label. Add `qa/required` if the PR ships changes that need QA validation, or `qa/skip-qa` if it does not (e.g., docs, tests, developer tooling, or no agent-impacting changes). The "Validate PR metadata" workflow fails until exactly one of those labels is set. Tick the matching checkbox in the PR template.
- Push the branch and open the PR before adding the changelog entry so the entry filename can reference the real PR number.

## Documentation
Expand Down
1 change: 1 addition & 0 deletions ddev/changelog.d/25537.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add `ddev validate all --scope` to run code or PR metadata validations separately, and run PR metadata validations in their own workflow that re-runs on description edits and label changes.
28 changes: 25 additions & 3 deletions ddev/src/ddev/cli/validate/all/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ def _load_validations(app: Application) -> dict[str, ValidationConfig]:
@click.command(short_help="Run all validations in parallel")
@click.argument("target", required=False)
@click.option("--fix", is_flag=True, help="Attempt to auto-fix issues (passes --sync/--fix to each validation).")
@click.option(
"--scope",
type=click.Choice(["all", "code", "pr-metadata"]),
default="all",
show_default=True,
help="Run all validations, only those reading repository files (code), or only PR metadata ones (pr-metadata).",
)
@click.option("--grace-period", type=float, default=5, help="Seconds to wait for stragglers after first completion.")
@click.option("--max-timeout", type=float, default=600, help="Maximum total seconds before the orchestrator stops.")
@click.option(
Expand All @@ -52,6 +59,7 @@ def all(
app: Application,
target: str | None,
fix: bool,
scope: str,
grace_period: float,
max_timeout: float,
subprocess_timeout: float,
Expand All @@ -62,25 +70,38 @@ 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 format_pr_comment, get_pr_number
from ddev.cli.validate.all.github import (
COMMENT_HEADING,
PR_METADATA_COMMENT_HEADING,
format_pr_comment,
get_pr_number,
)
from ddev.cli.validate.all.orchestrator import ValidationOrchestrator
from ddev.utils.github_actions import get_workflow_run_url, write_step_summary

selected = _load_validations(app)
report_heading = PR_METADATA_COMMENT_HEADING if scope == "pr-metadata" else COMMENT_HEADING
if not selected:
msg = (
"No validations are configured to run for this repository.\n"
"Add entries to the `validations` list in `.ddev/config.toml` or remove the validation workflow."
)
app.display_error(msg)
write_step_summary(f"## Validation Report\n\n> **Error:** {msg}")
write_step_summary(f"{report_heading}\n\n> **Error:** {msg}")
if pr_comment_output is not None:
comment_body = format_pr_comment({}, {}, target, [], error=msg)
comment_body = format_pr_comment({}, {}, target, [], error=msg, heading=report_heading)
if run_url := get_workflow_run_url():
comment_body += f"\n\n[View full run]({run_url})"
pr_comment_output.write_text(comment_body, encoding="utf-8")
app.abort()

if scope != "all":
pr_metadata = scope == "pr-metadata"
selected = {name: config for name, config in selected.items() if config.pr_metadata is pr_metadata}
if not selected:
app.display_info(f"No validations selected for scope {scope!r}.")
return

pr_number = get_pr_number(app)
orchestrator = ValidationOrchestrator(
app=app,
Expand All @@ -92,6 +113,7 @@ def all(
max_timeout=max_timeout,
subprocess_timeout=subprocess_timeout,
pr_comment_output=pr_comment_output,
comment_heading=report_heading,
)
orchestrator.run()

Expand Down
11 changes: 7 additions & 4 deletions ddev/src/ddev/cli/validate/all/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from ddev.cli.validate.all.orchestrator import ValidationConfig, ValidationResult

COMMENT_HEADING = "## Validation Report"
PR_METADATA_COMMENT_HEADING = "## PR Metadata Validation Report"


def parse_pr_number_from_event(event_path: str) -> int | None:
Expand Down Expand Up @@ -53,8 +54,8 @@ def get_pr_number(app: Application) -> int | None:
return None


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

incomplete = _build_incomplete_warning(expected_validations, results)
parts = _build_preamble(error, warning)
parts = _build_preamble(error, warning, heading)
parts.extend(incomplete)

if failures:
Expand All @@ -155,11 +157,12 @@ def format_step_summary(
*,
error: str | None = None,
warning: str | None = None,
heading: str = COMMENT_HEADING,
) -> str:
"""Format a flat summary table for the GitHub Actions step summary."""
has_failures = any(not r.success for r in results.values())

parts = _build_preamble(error, warning)
parts = _build_preamble(error, warning, heading)
parts.extend(_build_incomplete_warning(expected_validations, results))

parts.extend(_build_table(results, configs))
Expand Down
10 changes: 9 additions & 1 deletion ddev/src/ddev/cli/validate/all/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
class ValidationConfig:
description: str = ""
repo_wide: bool = False
# This validation reads only pull request metadata, not repository files, so it runs in the
# separate PR-metadata job that re-runs on description edits and label changes.
pr_metadata: bool = False
fix_flag: str | None = None
failure_guidance: str | None = None

Expand Down Expand Up @@ -109,6 +112,7 @@ class ValidationConfig:
"qa-label": ValidationConfig(
description="Validate the pull request declares whether it needs QA for the next Agent release",
repo_wide=True,
pr_metadata=True,
failure_guidance=(
"**To fix:** Choose exactly one QA label:\n"
"• `qa/required` if the PR needs QA validation.\n"
Expand Down Expand Up @@ -198,6 +202,7 @@ def __init__(
max_timeout: float = 600,
subprocess_timeout: float = SUBPROCESS_TIMEOUT,
pr_comment_output: Path | None = None,
comment_heading: str = COMMENT_HEADING,
):
validations = validations if validations is not None else list(VALIDATIONS)
super().__init__(
Expand All @@ -215,6 +220,7 @@ def __init__(
self._fix = fix
self._pr_number = pr_number
self._pr_comment_output = pr_comment_output
self._comment_heading = comment_heading
self._results: dict[str, ValidationResult] = {}

self.register_processor(
Expand Down Expand Up @@ -269,7 +275,7 @@ 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):
if comment.get("body", "").startswith(self._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}")
Expand All @@ -284,6 +290,7 @@ def _publish_report(self, exception: Exception | None) -> None:
self._validations,
error=error_msg,
warning=extra_warning,
heading=self._comment_heading,
)
write_step_summary(summary_body)

Expand All @@ -294,6 +301,7 @@ def _publish_report(self, exception: Exception | None) -> None:
self._validations,
error=error_msg,
warning=extra_warning,
heading=self._comment_heading,
)
if run_url := get_workflow_run_url():
comment_body += f"\n\n[View full run]({run_url})"
Expand Down
64 changes: 64 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,8 @@

from unittest.mock import patch

import pytest

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

from .conftest import completed_process
Expand Down Expand Up @@ -33,6 +35,46 @@ def fake_run(cmd, **kwargs):
assert set(invoked) == set(ALL_NAMES)


@pytest.mark.parametrize(
"scope, expected",
[
pytest.param("code", {"config"}, id="code"),
pytest.param("pr-metadata", {"qa-label"}, id="pr-metadata"),
pytest.param("all", {"config", "qa-label"}, id="all"),
],
)
def test_all_command_filters_validations_by_scope(ddev, scope, expected):
invoked: list[str] = []
selected = {name: VALIDATIONS[name] for name in ("config", "qa-label")}

def fake_run(cmd, **kwargs):
invoked.append(cmd[4])
return completed_process(returncode=0, stdout="ok")

with (
patch("ddev.cli.validate.all._load_validations", return_value=selected),
patch("subprocess.run", side_effect=fake_run),
):
result = ddev("validate", "all", "--scope", scope, *FAST_ORCHESTRATOR_OPTS)

assert result.exit_code == 0, result.output
assert set(invoked) == expected


def test_all_command_passes_when_scope_has_no_validations(ddev):
selected = {"qa-label": VALIDATIONS["qa-label"]}

with (
patch("ddev.cli.validate.all._load_validations", return_value=selected),
patch("subprocess.run") as run,
):
result = ddev("validate", "all", "--scope", "code", *FAST_ORCHESTRATOR_OPTS)

assert result.exit_code == 0, result.output
assert "No validations selected for scope 'code'." in result.output
run.assert_not_called()


def test_all_command_writes_pr_comment_before_aborting_without_github_credentials(ddev, tmp_path):
output = tmp_path / "pr-comment.md"
selected = {"config": VALIDATIONS["config"]}
Expand All @@ -55,6 +97,28 @@ def test_all_command_writes_pr_comment_before_aborting_without_github_credential
)


def test_all_command_uses_pr_metadata_comment_heading(ddev, tmp_path):
output = tmp_path / "pr-comment.md"
selected = {"qa-label": VALIDATIONS["qa-label"]}

with (
patch("ddev.cli.validate.all._load_validations", return_value=selected),
patch("subprocess.run", return_value=completed_process()),
):
result = ddev(
"validate",
"all",
"--scope",
"pr-metadata",
"--pr-comment-output",
str(output),
*FAST_ORCHESTRATOR_OPTS,
)

assert result.exit_code == 0, result.output
assert output.read_text(encoding="utf-8").startswith("## PR Metadata Validation Report\n")


def test_all_command_aborts_on_failure_with_details(ddev):
def fake_run(cmd, **kwargs):
if "config" in cmd:
Expand Down
27 changes: 26 additions & 1 deletion ddev/tests/cli/validate/all/test_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ def test_on_finalize_deletes_previous_validation_comments(mock_app):
mock_app.config.github.token = "fake-token"
mock_app.github.get_pull_request_comments.return_value = [
{"id": 100, "body": "## Validation Report\nold report"},
{"id": 200, "body": "unrelated comment"},
{"id": 200, "body": "## PR Metadata Validation Report\nold report"},
]

orch = ValidationOrchestrator(app=mock_app, validations=["config"], target=None, pr_number=42)
Expand All @@ -324,6 +324,31 @@ def test_on_finalize_deletes_previous_validation_comments(mock_app):
mock_app.github.post_pull_request_comment.assert_called_once()


def test_on_finalize_uses_separate_pr_metadata_report(mock_app, step_summary):
mock_app.config.github.token = "fake-token"
mock_app.github.get_pull_request_comments.return_value = [
{"id": 100, "body": "## Validation Report\nold report"},
{"id": 200, "body": "## PR Metadata Validation Report\nold report"},
]

orch = ValidationOrchestrator(
app=mock_app,
validations=["qa-label"],
target=None,
pr_number=42,
comment_heading="## PR Metadata Validation Report",
)
orch._results = {
"qa-label": ValidationResult(name="qa-label", success=True, stdout="ok", stderr="", duration=1.0),
}
asyncio.run(orch.on_finalize(exception=None))

mock_app.github.delete_comment.assert_called_once_with(200)
comment = mock_app.github.post_pull_request_comment.call_args[0][1]
assert comment.startswith("## PR Metadata Validation Report\n")
assert step_summary.read_text(encoding="utf-8").startswith("## PR Metadata Validation Report\n")


def test_on_finalize_includes_pr_warning_in_summary(mock_app, step_summary, monkeypatch):
monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request")

Expand Down
Loading