Skip to content

Commit cafdbff

Browse files
HadhemiDDclaude
andauthored
Add batch and job progress aggregate to the test gatherer (#24774)
* Add batch and job progress aggregate to the test gatherer The gatherer now publishes a DispatcherProgress -> BatchProgress -> JobProgress -> JobAttemptProgress snapshot on every UpdatePRComment, so the upcoming PR updater renders a complete immutable view of the run without touching GitHub API models or pipeline internals. The gatherer is initialized with the complete batch plan and emits revision 0 through build_initial_update(), and TestBatch/BatchFinished now carry an explicit batch_id instead of overloading BaseMessage.id as the logical batch identity. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Add changelog entry Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Apply ruff formatting Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Address review themes from the gatherer PR Derive done from the aggregate instead of a received-batch counter, so a batch that reports no jobs no longer blocks completion, and key every registry and the duplicate guard on batch_id rather than the message id. Merge a finished batch into its registered plan instead of rebuilding it from the message: jobs are preserved and executions are appended to each job's history, which is what a failed-job rerun needs. Model conclusion as WorkflowJobConclusion and errors as a ProgressError enum, make the attempt status non-optional, drop the invented "timed out" step name, and share one batch_status rule between the flat view and the aggregate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Carry only the snapshot on UpdatePRComment The message shipped done and four counter properties that the progress snapshot it also shipped already answered. Keep revision, which is ordering metadata the snapshot deliberately does not carry, and progress, which is everything else. WorkflowStatus and JobResult stay: they are built from the same sources as the aggregate rather than from it, and remain the gatherer's local registry of what each batch reported. They are just no longer published. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Shorten the comments and docstrings Same content, fewer words: multi-paragraph docstrings collapse to one or two sentences and the three-line inline comments to one. No logic change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Address the review comments Take the batch status from the workflow instead of rolling it up from the tracked jobs: a workflow also runs setup and finalization steps that can fail while every job passes. That makes BatchFinished.status the source of truth and leaves batch_status without a caller, so it goes and WorkflowStatus.status returns to counting. Validate the batch before gathering, so an unplanned or already-gathered message cannot organize artifacts into the shared output tree, where the names carry no batch and could overwrite a planned job's coverage. Keep jobs the plan never mentioned out of the totals, log them instead. Rename jobs to jobs_progress so it cannot be read as a tuple of BatchJob, collapse the workflow_job conditionals, and cut the comments back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 26b9a6d commit cafdbff

9 files changed

Lines changed: 1113 additions & 151 deletions

File tree

ddev/changelog.d/24774.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Adds the batch and job progress aggregate (`DispatcherProgress`) to the test gatherer.

ddev/src/ddev/cli/ci/tests/messages.py

Lines changed: 17 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
if TYPE_CHECKING:
1616
from pathlib import Path
1717

18+
from ddev.cli.ci.tests.progress import DispatcherProgress
1819
from ddev.utils.github_async.models import WorkflowJob
1920
from ddev.utils.junit import JUnitReport, JUnitTestCase
2021

@@ -163,17 +164,27 @@ def status(self) -> Status:
163164

164165
@dataclass
165166
class TestBatch(BaseMessage):
166-
"""Dispatched to trigger a matrix of test jobs."""
167+
"""Dispatched to trigger a matrix of test jobs.
167168
169+
``batch_id`` is the logical batch identity (e.g. ``batch-01``): assigned during planning, stable
170+
across workflow attempts, and distinct from ``BaseMessage.id``, which identifies one message.
171+
"""
172+
173+
batch_id: str
168174
job_list: list[BatchJob]
169175
jobs_count: int
170176
integrations: list[str]
171177

172178

173179
@dataclass
174180
class BatchFinished(BaseMessage):
175-
"""Emitted when a GitHub Actions test workflow has completed."""
181+
"""Emitted when a GitHub Actions test workflow has completed.
176182
183+
``batch_id`` is the identity of the ``TestBatch`` this run came from, so the gatherer can resolve
184+
it in the plan.
185+
"""
186+
187+
batch_id: str
177188
status: Status
178189
run_id: int
179190
workflow_url: str
@@ -186,28 +197,10 @@ class BatchFinished(BaseMessage):
186197
class UpdatePRComment(BaseMessage):
187198
"""Emitted per finished batch to request a PR comment update.
188199
189-
``revision`` is the gatherer's monotonic counter (one per consumed ``BatchFinished``); the
190-
PR-updater renders the latest and rejects stale revisions. ``done`` is ``True`` only on the
191-
revision that completes the final expected batch.
200+
``revision`` is ordering metadata: revision ``0`` is the initial plan, then one per consumed
201+
``BatchFinished``. The updater renders the latest and rejects stale revisions. ``progress`` is
202+
the whole payload, including whether the run is done and every count the comment needs.
192203
"""
193204

194205
revision: int
195-
done: bool
196-
workflows: list[WorkflowStatus]
197-
198-
@property
199-
def passed(self) -> int:
200-
return sum(workflow.success_count for workflow in self.workflows)
201-
202-
@property
203-
def failed(self) -> int:
204-
return sum(workflow.failed_count for workflow in self.workflows)
205-
206-
@property
207-
def skipped(self) -> int:
208-
return sum(workflow.skipped_count for workflow in self.workflows)
209-
210-
@property
211-
def complete(self) -> int:
212-
"""Total jobs finished so far (passed + failed + skipped across all gathered batches)."""
213-
return sum(len(workflow.results) for workflow in self.workflows)
206+
progress: DispatcherProgress
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
"""Aggregate state the gatherer owns and the PR updater renders.
5+
6+
``DispatcherProgress`` -> ``BatchProgress`` -> ``JobProgress`` -> ``JobAttemptProgress``: batches,
7+
their planned jobs, and each job's executions in attempt order.
8+
9+
``conclusion`` is the one GitHub value kept, as its enum, because it distinguishes outcomes
10+
(cancelled, timed out, action required) that ``Status`` collapses.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from dataclasses import dataclass
16+
from enum import Enum, StrEnum, auto
17+
from typing import TYPE_CHECKING
18+
19+
from ddev.cli.ci.tests.status import Status
20+
from ddev.utils.junit import TestStatus
21+
22+
if TYPE_CHECKING:
23+
from collections.abc import Iterator
24+
25+
from ddev.cli.ci.tests.messages import BatchJob
26+
from ddev.utils.github_async.models.workflow import WorkflowJobConclusion
27+
from ddev.utils.junit import JUnitReport, JUnitTestCase
28+
29+
30+
class ProgressError(StrEnum):
31+
"""Why a batch or execution is unavailable, as a closed set to branch on rather than prose."""
32+
33+
TIMED_OUT = auto()
34+
NO_JOB_RESULTS = auto()
35+
NO_ARTIFACTS = auto()
36+
37+
38+
class ExecutionState(Enum):
39+
"""Where an execution is in its lifecycle, orthogonal to its outcome (``Status``).
40+
41+
``RUNNING`` and ``RETRYING`` become reachable with the retry work.
42+
"""
43+
44+
PLANNED = "planned"
45+
RUNNING = "running"
46+
RETRYING = "retrying"
47+
FINISHED = "finished"
48+
49+
50+
@dataclass(frozen=True)
51+
class JobAttemptProgress:
52+
"""One observed execution of one planned job.
53+
54+
An attempt exists only because the job ran, so ``status`` is always known: an undetermined job
55+
has no attempt. ``attempt`` is its 1-based position in the job's history. ``job_id``,
56+
``conclusion`` and ``job_url`` are ``None`` when GitHub never reported the job.
57+
"""
58+
59+
attempt: int
60+
job_id: int | None
61+
status: Status
62+
conclusion: WorkflowJobConclusion | None
63+
failed_steps: tuple[str, ...]
64+
job_url: str | None
65+
reports: tuple[JUnitReport, ...]
66+
error: ProgressError | None = None
67+
68+
@property
69+
def failed_tests(self) -> list[JUnitTestCase]:
70+
"""Every failed/errored test case across this execution's reports."""
71+
return [
72+
case
73+
for report in self.reports
74+
for suite in report.test_suites
75+
for case in suite.test_cases
76+
if case.status in (TestStatus.FAILED, TestStatus.ERROR)
77+
]
78+
79+
80+
@dataclass(frozen=True)
81+
class JobProgress:
82+
"""One logical planned job throughout execution.
83+
84+
``attempts`` can be sparse: a job that already succeeded does not run again in a failed-job rerun.
85+
"""
86+
87+
job: BatchJob
88+
attempts: tuple[JobAttemptProgress, ...]
89+
90+
@property
91+
def latest(self) -> JobAttemptProgress | None:
92+
"""The most recent execution: the only attempt that counts toward totals."""
93+
return self.attempts[-1] if self.attempts else None
94+
95+
@property
96+
def retry_count(self) -> int:
97+
"""Executions minus one. Not ``run_attempt - 1``: histories can be sparse."""
98+
return max(0, len(self.attempts) - 1)
99+
100+
101+
@dataclass(frozen=True)
102+
class BatchProgress:
103+
"""One logical batch, from planning through its terminal outcome.
104+
105+
``status`` is the workflow's own, not a roll-up of ``jobs_progress``: a workflow can fail in a
106+
step no tracked job covers. It stays ``None`` until ``FINISHED``.
107+
"""
108+
109+
batch_id: str
110+
run_id: int | None
111+
workflow_url: str | None
112+
state: ExecutionState
113+
status: Status | None
114+
current_attempt: int | None
115+
max_attempts: int
116+
retries_remaining: int
117+
retrying_jobs: tuple[BatchJob, ...]
118+
jobs_progress: tuple[JobProgress, ...]
119+
error: ProgressError | None = None
120+
121+
122+
@dataclass(frozen=True)
123+
class DispatcherProgress:
124+
"""The complete point-in-time aggregate across all batches.
125+
126+
Carries all state known at its revision, so the PR updater renders the newest snapshot and
127+
discards the rest. The counters derive from ``JobProgress.latest`` only, so a job retried to
128+
success counts once.
129+
"""
130+
131+
batches: tuple[BatchProgress, ...]
132+
done: bool
133+
134+
@property
135+
def passed(self) -> int:
136+
return self._count(Status.SUCCESS)
137+
138+
@property
139+
def failed(self) -> int:
140+
return self._count(Status.FAILURE)
141+
142+
@property
143+
def skipped(self) -> int:
144+
return self._count(Status.SKIPPED)
145+
146+
@property
147+
def complete(self) -> int:
148+
"""Planned jobs that have run."""
149+
return sum(1 for job in self._jobs_progress if job.latest is not None)
150+
151+
@property
152+
def total(self) -> int:
153+
"""Every planned job, run or not."""
154+
return sum(1 for _ in self._jobs_progress)
155+
156+
@property
157+
def _jobs_progress(self) -> Iterator[JobProgress]:
158+
return (job for batch in self.batches for job in batch.jobs_progress)
159+
160+
def _count(self, status: Status) -> int:
161+
return sum(1 for job in self._jobs_progress if job.latest is not None and job.latest.status == status)

0 commit comments

Comments
 (0)