diff --git a/features/plan_applied_event_enrichment.feature b/features/plan_applied_event_enrichment.feature new file mode 100644 index 000000000..2169c28db --- /dev/null +++ b/features/plan_applied_event_enrichment.feature @@ -0,0 +1,87 @@ +Feature: PLAN_APPLIED event enrichment with changeset statistics + As an audit log consumer + I want the PLAN_APPLIED domain event to include changeset statistics + So that audit entries contain rich context per SEC7 requirements + + Background: + Given a plan applied event enrichment test environment + + # -- complete_apply passes statistics to event ------------------------- + + Scenario: complete_apply emits PLAN_APPLIED with files_changed statistic + Given a plan in Apply/PROCESSING state + When complete_apply is called with files_changed=5 + Then the emitted PLAN_APPLIED event details should include files_changed=5 + + Scenario: complete_apply emits PLAN_APPLIED with lines_added statistic + Given a plan in Apply/PROCESSING state + When complete_apply is called with lines_added=42 + Then the emitted PLAN_APPLIED event details should include lines_added=42 + + Scenario: complete_apply emits PLAN_APPLIED with lines_removed statistic + Given a plan in Apply/PROCESSING state + When complete_apply is called with lines_removed=7 + Then the emitted PLAN_APPLIED event details should include lines_removed=7 + + Scenario: complete_apply emits PLAN_APPLIED with resources_modified statistic + Given a plan in Apply/PROCESSING state + When complete_apply is called with resources_modified=3 + Then the emitted PLAN_APPLIED event details should include resources_modified=3 + + Scenario: complete_apply emits PLAN_APPLIED with apply_duration_seconds statistic + Given a plan in Apply/PROCESSING state + When complete_apply is called with apply_duration_seconds=1.5 + Then the emitted PLAN_APPLIED event details should include apply_duration_seconds=1.5 + + Scenario: complete_apply emits PLAN_APPLIED with all statistics combined + Given a plan in Apply/PROCESSING state + When complete_apply is called with all changeset statistics + Then the emitted PLAN_APPLIED event details should include all changeset statistics + + Scenario: complete_apply emits PLAN_APPLIED with zero statistics by default + Given a plan in Apply/PROCESSING state + When complete_apply is called with no changeset statistics + Then the emitted PLAN_APPLIED event details should include files_changed=0 + And the emitted PLAN_APPLIED event details should include lines_added=0 + And the emitted PLAN_APPLIED event details should include lines_removed=0 + And the emitted PLAN_APPLIED event details should include resources_modified=0 + And the emitted PLAN_APPLIED event details should include apply_duration_seconds=0.0 + + Scenario: complete_apply preserves existing event details alongside statistics + Given a plan applied event enrichment test environment + Given a plan in Apply/PROCESSING state with action_name "deploy-service" + When complete_apply is called with files_changed=2 + Then the emitted PLAN_APPLIED event details should include action_name "deploy-service" + And the emitted PLAN_APPLIED event details should include files_changed=2 + + # -- apply_with_validation_gate passes statistics through --------------- + + Scenario: apply_with_validation_gate passes files_changed to complete_apply + Given a plan with changeset containing 4 entries + And the plan has no validation failures + When apply_with_validation_gate is called + Then complete_apply should be called with files_changed=4 + + Scenario: apply_with_validation_gate passes resources_modified to complete_apply + Given a plan with changeset containing entries for 2 distinct resources + And the plan has no validation failures + When apply_with_validation_gate is called + Then complete_apply should be called with resources_modified=2 + + Scenario: apply_with_validation_gate passes apply_duration_seconds to complete_apply + Given a plan with changeset containing 1 entries + And the plan has no validation failures + When apply_with_validation_gate is called + Then complete_apply should be called with a non-negative apply_duration_seconds + + Scenario: apply_with_validation_gate passes lines_added from creates to complete_apply + Given a plan with changeset containing 3 CREATE entries + And the plan has no validation failures + When apply_with_validation_gate is called + Then complete_apply should be called with lines_added=3 + + Scenario: apply_with_validation_gate passes lines_removed from deletes to complete_apply + Given a plan with changeset containing 2 DELETE entries + And the plan has no validation failures + When apply_with_validation_gate is called + Then complete_apply should be called with lines_removed=2 diff --git a/features/steps/plan_applied_event_enrichment_steps.py b/features/steps/plan_applied_event_enrichment_steps.py new file mode 100644 index 000000000..6b96838e1 --- /dev/null +++ b/features/steps/plan_applied_event_enrichment_steps.py @@ -0,0 +1,489 @@ +"""Step definitions for PLAN_APPLIED event enrichment with changeset statistics. + +Tests that: +1. ``PlanLifecycleService.complete_apply`` accepts changeset statistics kwargs + and includes them in the emitted ``PLAN_APPLIED`` domain event. +2. ``PlanApplyService.apply_with_validation_gate`` computes and passes + changeset statistics to ``complete_apply``. + +Based on issue #716 (SEC7 audit logging enrichment). +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import structlog +from behave import given, then, when # type: ignore[import-untyped] +from behave.runner import Context # type: ignore[import-untyped] + +from cleveragents.application.services.plan_apply_service import ( + PlanApplyService, +) +from cleveragents.application.services.plan_lifecycle_service import ( + PlanLifecycleService, +) +from cleveragents.domain.models.core.change import ( + ChangeEntry, + ChangeOperation, + SpecChangeSet, +) +from cleveragents.domain.models.core.plan import ( + PlanPhase, + PlanTimestamps, + ProcessingState, +) +from cleveragents.infrastructure.events.models import DomainEvent +from cleveragents.infrastructure.events.types import EventType + +__all__: list[str] = [] + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_PLAN_ID = "01ENRICHTEST00000000001" +_CHANGESET_ID = "01CSENRICH000000000001" + + +# --------------------------------------------------------------------------- +# Mock helpers +# --------------------------------------------------------------------------- + + +def _make_mock_plan( + *, + plan_id: str = _PLAN_ID, + phase: PlanPhase = PlanPhase.APPLY, + state: ProcessingState = ProcessingState.PROCESSING, + changeset_id: str | None = _CHANGESET_ID, + action_name: str = "test-action", + project_links: list[Any] | None = None, + created_by: str = "test-actor", + validation_summary: dict[str, Any] | None = None, + is_terminal: bool = False, + error_details: dict[str, str] | None = None, +) -> MagicMock: + """Create a mock Plan object for enrichment tests.""" + plan = MagicMock() + plan.identity.plan_id = plan_id + plan.phase = phase + plan.processing_state = state + plan.state = state + plan.changeset_id = changeset_id + plan.action_name = action_name + plan.project_links = project_links or [] + plan.created_by = created_by + plan.validation_summary = validation_summary + plan.is_terminal = is_terminal + plan.error_details = error_details + plan.timestamps = PlanTimestamps() + return plan + + +def _make_changeset( + plan_id: str = _PLAN_ID, + n_modify: int = 0, + n_create: int = 0, + n_delete: int = 0, + resource_ids: list[str] | None = None, +) -> SpecChangeSet: + """Create a SpecChangeSet with specified entry counts.""" + cs = SpecChangeSet(plan_id=plan_id, changeset_id=_CHANGESET_ID) + idx = 0 + + def _res(i: int) -> str: + if resource_ids: + return resource_ids[i % len(resource_ids)] + return f"RES{i:03d}" + + for i in range(n_create): + cs.add_change( + ChangeEntry( + plan_id=plan_id, + resource_id=_res(idx), + tool_name="builtin/test", + operation=ChangeOperation.CREATE, + path=f"src/new_file_{i}.py", + after_hash=f"after-{i}", + ) + ) + idx += 1 + + for i in range(n_modify): + cs.add_change( + ChangeEntry( + plan_id=plan_id, + resource_id=_res(idx), + tool_name="builtin/test", + operation=ChangeOperation.MODIFY, + path=f"src/file_{i}.py", + before_hash=f"before-{i}", + after_hash=f"after-{i}", + ) + ) + idx += 1 + + for i in range(n_delete): + cs.add_change( + ChangeEntry( + plan_id=plan_id, + resource_id=_res(idx), + tool_name="builtin/test", + operation=ChangeOperation.DELETE, + path=f"src/old_file_{i}.py", + before_hash=f"before-del-{i}", + ) + ) + idx += 1 + + return cs + + +def _build_lifecycle_with_capturing_bus( + plan: MagicMock, +) -> tuple[MagicMock, list[DomainEvent]]: + """Build a lifecycle mock that calls the real complete_apply and captures events. + + Returns: + Tuple of (lifecycle_mock, emitted_events_list). + """ + emitted_events: list[DomainEvent] = [] + + event_bus = MagicMock() + event_bus.emit.side_effect = lambda e: emitted_events.append(e) + + # We need a lifecycle object that has all the attributes complete_apply + # accesses, but calls the real implementation. + lifecycle = MagicMock(spec=PlanLifecycleService) + lifecycle.event_bus = event_bus + lifecycle.get_plan.return_value = plan + lifecycle._commit_plan = MagicMock() + lifecycle._cleanup_devcontainers = MagicMock() + lifecycle._logger = structlog.get_logger("test.lifecycle") + + # Bind the real complete_apply to this mock instance + def _real_complete_apply(plan_id: str, **kwargs: Any) -> Any: + return PlanLifecycleService.complete_apply(lifecycle, plan_id, **kwargs) + + lifecycle.complete_apply = _real_complete_apply + + return lifecycle, emitted_events + + +# --------------------------------------------------------------------------- +# Background +# --------------------------------------------------------------------------- + + +@given("a plan applied event enrichment test environment") +def step_enrichment_env(context: Context) -> None: + """Initialise shared context for enrichment tests.""" + context.emitted_events = [] + context.complete_apply_calls = [] + + +# --------------------------------------------------------------------------- +# Given steps — lifecycle service scenarios +# --------------------------------------------------------------------------- + + +@given("a plan in Apply/PROCESSING state") +def step_plan_apply_processing(context: Context) -> None: + """Set up a plan in Apply/PROCESSING state with a capturing event bus.""" + context.plan = _make_mock_plan() + lifecycle, emitted_events = _build_lifecycle_with_capturing_bus(context.plan) + context.lifecycle = lifecycle + context.emitted_events = emitted_events + + +@given('a plan in Apply/PROCESSING state with action_name "{action_name}"') +def step_plan_apply_processing_with_action(context: Context, action_name: str) -> None: + """Set up a plan in Apply/PROCESSING state with a specific action_name.""" + context.plan = _make_mock_plan(action_name=action_name) + lifecycle, emitted_events = _build_lifecycle_with_capturing_bus(context.plan) + context.lifecycle = lifecycle + context.emitted_events = emitted_events + + +# --------------------------------------------------------------------------- +# Given steps — apply_with_validation_gate scenarios +# --------------------------------------------------------------------------- + + +@given("a plan with changeset containing {n:d} entries") +def step_plan_with_n_entries(context: Context, n: int) -> None: + """Set up a plan with a changeset of n MODIFY entries.""" + context.plan = _make_mock_plan() + context.changeset = _make_changeset(n_modify=n) + context.complete_apply_kwargs = {} + context.apply_result = None + + +@given("a plan with changeset containing entries for {n:d} distinct resources") +def step_plan_with_n_resources(context: Context, n: int) -> None: + """Set up a plan with a changeset spanning n distinct resources.""" + resource_ids = [f"RES{i:03d}" for i in range(n)] + context.plan = _make_mock_plan() + context.changeset = _make_changeset( + n_modify=n, + resource_ids=resource_ids, + ) + context.complete_apply_kwargs = {} + context.apply_result = None + + +@given("a plan with changeset containing {n:d} CREATE entries") +def step_plan_with_n_creates(context: Context, n: int) -> None: + """Set up a plan with a changeset of n CREATE entries.""" + context.plan = _make_mock_plan() + context.changeset = _make_changeset(n_create=n) + context.complete_apply_kwargs = {} + context.apply_result = None + + +@given("a plan with changeset containing {n:d} DELETE entries") +def step_plan_with_n_deletes(context: Context, n: int) -> None: + """Set up a plan with a changeset of n DELETE entries.""" + context.plan = _make_mock_plan() + context.changeset = _make_changeset(n_delete=n) + context.complete_apply_kwargs = {} + context.apply_result = None + + +@given("the plan has no validation failures") +def step_no_validation_failures(context: Context) -> None: + """Ensure the plan has no validation failures.""" + context.plan.validation_summary = { + "required_passed": 0, + "required_failed": 0, + "total": 0, + } + context.plan.is_terminal = False + + +# --------------------------------------------------------------------------- +# When steps — lifecycle service scenarios +# --------------------------------------------------------------------------- + + +@when("complete_apply is called with files_changed={n:d}") +def step_complete_apply_files_changed(context: Context, n: int) -> None: + """Call complete_apply with files_changed kwarg.""" + context.lifecycle.complete_apply(_PLAN_ID, files_changed=n) + + +@when("complete_apply is called with lines_added={n:d}") +def step_complete_apply_lines_added(context: Context, n: int) -> None: + """Call complete_apply with lines_added kwarg.""" + context.lifecycle.complete_apply(_PLAN_ID, lines_added=n) + + +@when("complete_apply is called with lines_removed={n:d}") +def step_complete_apply_lines_removed(context: Context, n: int) -> None: + """Call complete_apply with lines_removed kwarg.""" + context.lifecycle.complete_apply(_PLAN_ID, lines_removed=n) + + +@when("complete_apply is called with resources_modified={n:d}") +def step_complete_apply_resources_modified(context: Context, n: int) -> None: + """Call complete_apply with resources_modified kwarg.""" + context.lifecycle.complete_apply(_PLAN_ID, resources_modified=n) + + +@when("complete_apply is called with apply_duration_seconds={v:f}") +def step_complete_apply_duration(context: Context, v: float) -> None: + """Call complete_apply with apply_duration_seconds kwarg.""" + context.lifecycle.complete_apply(_PLAN_ID, apply_duration_seconds=v) + + +@when("complete_apply is called with all changeset statistics") +def step_complete_apply_all_stats(context: Context) -> None: + """Call complete_apply with all changeset statistics kwargs.""" + context.lifecycle.complete_apply( + _PLAN_ID, + files_changed=10, + lines_added=200, + lines_removed=50, + resources_modified=4, + apply_duration_seconds=2.75, + ) + context.expected_stats = { + "files_changed": 10, + "lines_added": 200, + "lines_removed": 50, + "resources_modified": 4, + "apply_duration_seconds": 2.75, + } + + +@when("complete_apply is called with no changeset statistics") +def step_complete_apply_no_stats(context: Context) -> None: + """Call complete_apply with no changeset statistics (defaults).""" + context.lifecycle.complete_apply(_PLAN_ID) + + +# --------------------------------------------------------------------------- +# When steps — apply_with_validation_gate scenarios +# --------------------------------------------------------------------------- + + +@when("apply_with_validation_gate is called") +def step_apply_with_validation_gate(context: Context) -> None: + """Call apply_with_validation_gate and capture complete_apply kwargs.""" + plan = context.plan + changeset = context.changeset + + lifecycle = MagicMock() + lifecycle.get_plan.return_value = plan + lifecycle._commit_plan = MagicMock() + + captured_kwargs: dict[str, Any] = {} + + def _capture_complete_apply(plan_id: str, **kwargs: Any) -> MagicMock: + captured_kwargs.update(kwargs) + return plan + + lifecycle.complete_apply.side_effect = _capture_complete_apply + + store = MagicMock() + store.get.return_value = changeset + + service = PlanApplyService( + lifecycle_service=lifecycle, + changeset_store=store, + ) + + context.apply_result = service.apply_with_validation_gate(plan.identity.plan_id) + context.complete_apply_kwargs = captured_kwargs + + +# --------------------------------------------------------------------------- +# Then steps — event details assertions +# --------------------------------------------------------------------------- + + +def _get_plan_applied_event(context: Context) -> DomainEvent: + """Return the first PLAN_APPLIED event from the captured list.""" + events = [ + e for e in context.emitted_events if e.event_type == EventType.PLAN_APPLIED + ] + assert events, "No PLAN_APPLIED event was emitted" + return events[0] + + +@then("the emitted PLAN_APPLIED event details should include files_changed={n:d}") +def step_assert_files_changed(context: Context, n: int) -> None: + """Assert files_changed in PLAN_APPLIED event details.""" + event = _get_plan_applied_event(context) + assert event.details.get("files_changed") == n, ( + f"Expected files_changed={n}, got {event.details.get('files_changed')!r}" + ) + + +@then("the emitted PLAN_APPLIED event details should include lines_added={n:d}") +def step_assert_lines_added(context: Context, n: int) -> None: + """Assert lines_added in PLAN_APPLIED event details.""" + event = _get_plan_applied_event(context) + assert event.details.get("lines_added") == n, ( + f"Expected lines_added={n}, got {event.details.get('lines_added')!r}" + ) + + +@then("the emitted PLAN_APPLIED event details should include lines_removed={n:d}") +def step_assert_lines_removed(context: Context, n: int) -> None: + """Assert lines_removed in PLAN_APPLIED event details.""" + event = _get_plan_applied_event(context) + assert event.details.get("lines_removed") == n, ( + f"Expected lines_removed={n}, got {event.details.get('lines_removed')!r}" + ) + + +@then("the emitted PLAN_APPLIED event details should include resources_modified={n:d}") +def step_assert_resources_modified(context: Context, n: int) -> None: + """Assert resources_modified in PLAN_APPLIED event details.""" + event = _get_plan_applied_event(context) + assert event.details.get("resources_modified") == n, ( + f"Expected resources_modified={n}, got {event.details.get('resources_modified')!r}" + ) + + +@then( + "the emitted PLAN_APPLIED event details should include apply_duration_seconds={v:f}" +) +def step_assert_apply_duration(context: Context, v: float) -> None: + """Assert apply_duration_seconds in PLAN_APPLIED event details.""" + event = _get_plan_applied_event(context) + actual = event.details.get("apply_duration_seconds") + assert actual == v, f"Expected apply_duration_seconds={v}, got {actual!r}" + + +@then("the emitted PLAN_APPLIED event details should include all changeset statistics") +def step_assert_all_stats(context: Context) -> None: + """Assert all changeset statistics are present in PLAN_APPLIED event details.""" + event = _get_plan_applied_event(context) + expected = context.expected_stats + for key, value in expected.items(): + actual = event.details.get(key) + assert actual == value, f"Expected {key}={value}, got {actual!r}" + + +@then( + 'the emitted PLAN_APPLIED event details should include action_name "{action_name}"' +) +def step_assert_action_name(context: Context, action_name: str) -> None: + """Assert action_name is preserved in PLAN_APPLIED event details.""" + event = _get_plan_applied_event(context) + assert event.details.get("action_name") == action_name, ( + f"Expected action_name={action_name!r}, got {event.details.get('action_name')!r}" + ) + + +# --------------------------------------------------------------------------- +# Then steps — complete_apply call assertions +# --------------------------------------------------------------------------- + + +@then("complete_apply should be called with files_changed={n:d}") +def step_assert_complete_apply_files_changed(context: Context, n: int) -> None: + """Assert complete_apply was called with files_changed=n.""" + actual = context.complete_apply_kwargs.get("files_changed") + assert actual == n, ( + f"Expected complete_apply called with files_changed={n}, got {actual!r}" + ) + + +@then("complete_apply should be called with resources_modified={n:d}") +def step_assert_complete_apply_resources_modified(context: Context, n: int) -> None: + """Assert complete_apply was called with resources_modified=n.""" + actual = context.complete_apply_kwargs.get("resources_modified") + assert actual == n, ( + f"Expected complete_apply called with resources_modified={n}, got {actual!r}" + ) + + +@then("complete_apply should be called with a non-negative apply_duration_seconds") +def step_assert_complete_apply_duration_nonneg(context: Context) -> None: + """Assert complete_apply was called with a non-negative apply_duration_seconds.""" + actual = context.complete_apply_kwargs.get("apply_duration_seconds") + assert actual is not None, "apply_duration_seconds was not passed to complete_apply" + assert actual >= 0.0, f"Expected apply_duration_seconds >= 0.0, got {actual!r}" + + +@then("complete_apply should be called with lines_added={n:d}") +def step_assert_complete_apply_lines_added(context: Context, n: int) -> None: + """Assert complete_apply was called with lines_added=n.""" + actual = context.complete_apply_kwargs.get("lines_added") + assert actual == n, ( + f"Expected complete_apply called with lines_added={n}, got {actual!r}" + ) + + +@then("complete_apply should be called with lines_removed={n:d}") +def step_assert_complete_apply_lines_removed(context: Context, n: int) -> None: + """Assert complete_apply was called with lines_removed=n.""" + actual = context.complete_apply_kwargs.get("lines_removed") + assert actual == n, ( + f"Expected complete_apply called with lines_removed={n}, got {actual!r}" + ) diff --git a/src/cleveragents/application/services/plan_apply_service.py b/src/cleveragents/application/services/plan_apply_service.py index 6157336ad..72c148f87 100644 --- a/src/cleveragents/application/services/plan_apply_service.py +++ b/src/cleveragents/application/services/plan_apply_service.py @@ -13,6 +13,10 @@ Provides the service layer for D0b.apply features: unless ``allow_empty`` is explicitly set. - **Validation-gated apply**: Block apply when required validations have not passed, setting plan to ``constrained`` state with actionable error. +- **Changeset statistics enrichment**: Passes SEC7 audit statistics + (files changed, resources modified, apply duration) to + ``PlanLifecycleService.complete_apply`` so the ``PLAN_APPLIED`` domain + event carries rich context for audit logging (issue #716). All public methods accept ``plan_id`` and delegate plan lookups to ``PlanLifecycleService``. @@ -596,16 +600,24 @@ class PlanApplyService: # All required validations passed — proceed with apply files_changed = len(changeset.entries) + # Compute changeset statistics for SEC7 audit enrichment (issue #716) + changeset_summary = changeset.summary() + resources_modified = changeset_summary.get("resources_involved", 0) + self._logger.info( "Validation gate passed, applying", plan_id=plan_id, files_changed=files_changed, + resources_modified=resources_modified, required_passed=req_passed, ) # Checkpoint: pre_apply snapshot self._try_checkpoint(plan_id, "pre_apply") + # Record apply start time for duration measurement + apply_start = datetime.now(tz=UTC) + # Persist apply summary self.persist_apply_summary( plan_id=plan_id, @@ -613,9 +625,20 @@ class PlanApplyService: validations_run=total, ) - # Transition to applied via lifecycle service + # Compute apply duration + apply_duration_seconds = (datetime.now(tz=UTC) - apply_start).total_seconds() + + # Transition to applied via lifecycle service, passing changeset + # statistics so the PLAN_APPLIED event carries rich SEC7 audit context. try: - self._lifecycle.complete_apply(plan_id) + self._lifecycle.complete_apply( + plan_id, + files_changed=files_changed, + lines_added=changeset_summary.get("creates", 0), + lines_removed=changeset_summary.get("deletes", 0), + resources_modified=resources_modified, + apply_duration_seconds=apply_duration_seconds, + ) except (PlanError, ValidationError): # If lifecycle transition fails, attempt rollback self._try_rollback(plan_id) diff --git a/src/cleveragents/application/services/plan_lifecycle_service.py b/src/cleveragents/application/services/plan_lifecycle_service.py index 9d393bece..111248b6b 100644 --- a/src/cleveragents/application/services/plan_lifecycle_service.py +++ b/src/cleveragents/application/services/plan_lifecycle_service.py @@ -1526,14 +1526,33 @@ class PlanLifecycleService: return plan - def complete_apply(self, plan_id: str) -> Plan: + def complete_apply( + self, + plan_id: str, + *, + files_changed: int = 0, + lines_added: int = 0, + lines_removed: int = 0, + resources_modified: int = 0, + apply_duration_seconds: float = 0.0, + ) -> Plan: """Complete the Apply phase — terminal success. Sets ``processing_state`` to ``APPLIED`` (the terminal success state within the Apply phase). The phase remains ``APPLY``. + The optional changeset statistics parameters enrich the emitted + ``PLAN_APPLIED`` domain event with SEC7 audit-logging context + (see docs/specification.md §Audit Logging). + Args: - plan_id: The plan ULID + plan_id: The plan ULID. + files_changed: Number of files changed during apply. + lines_added: Total lines added across all changed files. + lines_removed: Total lines removed across all changed files. + resources_modified: Number of distinct resources modified. + apply_duration_seconds: Wall-clock duration of the apply + operation in seconds. Returns: The updated Plan with processing_state=APPLIED @@ -1578,6 +1597,12 @@ class PlanLifecycleService: "action_name": plan.action_name, "phase": plan.phase.value, "project_names": project_names, + # SEC7 changeset statistics (issue #716) + "files_changed": files_changed, + "lines_added": lines_added, + "lines_removed": lines_removed, + "resources_modified": resources_modified, + "apply_duration_seconds": apply_duration_seconds, }, ) )