You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by gn...@apache.org on 2026/09/14 07:24:33 UTC
(maven) branch master updated: [MNG-8547] Expose repository events through the Maven API
This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven.git
The following commit(s) were added to refs/heads/master by this push:
new c7b164f761 [MNG-8547] Expose repository events through the Maven API
c7b164f761 is described below
commit c7b164f76189be25a2c6a48ffe74ff3afc39fcc2
Author: Goutam Adwant <86...@users.noreply.github.com>
AuthorDate: Mon Sep 14 00:24:27 2026 -0700
[MNG-8547] Expose repository events through the Maven API
[MNG-8547] Expose repository events through the Maven API
Add Maven API repository event and listener types and bridge all Resolver
repository callbacks without exposing Resolver types.
New API types:
- Event: base interface for all events, with session() accessor and
deprecated getSession() compat default
- ExecutionEvent: extends Event, moves to maven-api-core with noun-style
accessors (project(), mojoExecution(), etc.) and ExecutionEventType enum
- ExecutionListener: extends Listener, provides a default onEvent()
that dispatches to the appropriate typed callback
- RepositoryEvent: extends Event, exposes repository interactions
(artifact/metadata resolution, deployment, installation) via noun-style
accessors and RepositoryEventType enum
- RepositoryListener: extends Listener, provides a default onEvent()
that dispatches to the appropriate typed callback
- RepositoryMetadata: @Immutable value type with noun-style accessors
- Session.registerListener() scoped to the underlying Resolver session
Implementation:
- MavenRepositoryListener bridges Resolver callbacks to Maven API events
without leaking resolver-api types into the SPI
- DefaultRepositoryEvent wraps the Resolver event with lazy field init
- AbstractSession registers MavenRepositoryListener and routes events
- EventSpyImpl simplified to a single listener.onEvent(event) call
- SessionStub returns empty immutable collection (non-null contract)
Deleted types:
- SessionEvent (redundant; Event is now the base)
- TypedListener (each sub-interface provides its own default onEvent())
Tests: MavenRepositoryListenerTest, EventSpyImplTest,
DefaultRepositorySystemSessionFactoryTest cover event mapping,
combined listener registration, derived-session behavior,
concurrent updates, and failure isolation. Document combined listener
dispatch pattern for listeners implementing both ExecutionListener
and RepositoryListener.
Co-authored-by: Guillaume Nodet <gn...@gmail.com>
---
.../src/main/java/org/apache/maven/api/Event.java | 66 +++-
.../api/{Listener.java => ExecutionEvent.java} | 34 +-
.../api/{Listener.java => ExecutionEventType.java} | 29 +-
.../org/apache/maven/api/ExecutionListener.java | 192 ++++++++++++
.../main/java/org/apache/maven/api/Listener.java | 21 +-
.../java/org/apache/maven/api/RepositoryEvent.java | 70 +++++
.../{Listener.java => RepositoryEventType.java} | 31 +-
.../org/apache/maven/api/RepositoryListener.java | 209 +++++++++++++
.../org/apache/maven/api/RepositoryMetadata.java | 93 ++++++
.../main/java/org/apache/maven/api/Session.java | 4 +-
.../DefaultRepositorySystemSessionFactory.java | 4 +-
.../apache/maven/internal/impl/DefaultEvent.java | 32 +-
.../apache/maven/internal/impl/EventSpyImpl.java | 22 +-
.../DefaultRepositorySystemSessionFactoryTest.java | 41 +++
.../maven/internal/impl/EventSpyImplTest.java | 337 ++++++++++++++++++++
.../org/apache/maven/impl/AbstractSession.java | 9 +-
.../apache/maven/impl/DefaultRepositoryEvent.java | 161 ++++++++++
.../apache/maven/impl/MavenRepositoryListener.java | 160 ++++++++++
.../apache/maven/impl/standalone/ApiRunner.java | 2 +
.../maven/impl/MavenRepositoryListenerTest.java | 347 +++++++++++++++++++++
.../maven/impl/standalone/RequestTraceTest.java | 32 +-
.../maven/testing/plugin/stubs/SessionStub.java | 2 +-
22 files changed, 1823 insertions(+), 75 deletions(-)
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/Event.java b/api/maven-api-core/src/main/java/org/apache/maven/api/Event.java
index 0323abd435..22f725e675 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/Event.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/Event.java
@@ -21,54 +21,100 @@
import java.util.Optional;
import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Immutable;
import org.apache.maven.api.annotations.Nonnull;
/**
- * Event sent by maven during various phases of the build process.
- * Such events can be listened to using {@link Listener}s objects
- * registered in the {@link Session}.
+ * Base interface for all Maven events.
+ * Specific event families extend this interface to provide typed event data.
+ * Events can be listened to using {@link Listener} objects registered in the {@link Session}.
*
+ * @see ExecutionEvent
+ * @see RepositoryEvent
+ * @see Listener
* @since 4.0.0
*/
@Experimental
+@Immutable
public interface Event {
/**
- * Gets the type of the event.
+ * Returns the session from which this event originates.
*
- * @return the type of the event, never {@code null}
+ * @return the current session, never {@code null}
*/
@Nonnull
- EventType getType();
+ Session session();
/**
* Gets the session from which this event originates.
*
* @return the current session, never {@code null}
+ * @deprecated Use {@link #session()} instead.
+ */
+ @Deprecated(since = "4.1.0", forRemoval = true)
+ @Nonnull
+ default Session getSession() {
+ return session();
+ }
+
+ /**
+ * Gets the type of the event.
+ *
+ * @return the execution event type, never {@code null}; only meaningful when this event is an {@link ExecutionEvent}
+ * @throws UnsupportedOperationException if this event is not an {@link ExecutionEvent}
+ * @deprecated Use {@link ExecutionEvent#type()} instead.
*/
+ @Deprecated(since = "4.1.0", forRemoval = true)
@Nonnull
- Session getSession();
+ default EventType getType() {
+ if (this instanceof ExecutionEvent ee) {
+ return EventType.valueOf(ee.type().name());
+ }
+ throw new UnsupportedOperationException("getType() is only supported on ExecutionEvent instances");
+ }
/**
* Gets the current project (if any).
*
* @return the current project or {@code empty()} if not applicable
+ * @deprecated Use {@link ExecutionEvent#project()} instead.
*/
+ @Deprecated(since = "4.1.0", forRemoval = true)
@Nonnull
- Optional<Project> getProject();
+ default Optional<Project> getProject() {
+ if (this instanceof ExecutionEvent ee) {
+ return ee.project();
+ }
+ return Optional.empty();
+ }
/**
* Gets the current mojo execution (if any).
*
* @return the current mojo execution or {@code empty()} if not applicable
+ * @deprecated Use {@link ExecutionEvent#mojoExecution()} instead.
*/
+ @Deprecated(since = "4.1.0", forRemoval = true)
@Nonnull
- Optional<MojoExecution> getMojoExecution();
+ default Optional<MojoExecution> getMojoExecution() {
+ if (this instanceof ExecutionEvent ee) {
+ return ee.mojoExecution();
+ }
+ return Optional.empty();
+ }
/**
* Gets the exception that caused the event (if any).
*
* @return the exception or {@code empty()} if none
+ * @deprecated Use {@link ExecutionEvent#exception()} instead.
*/
- Optional<Exception> getException();
+ @Deprecated(since = "4.1.0", forRemoval = true)
+ default Optional<Exception> getException() {
+ if (this instanceof ExecutionEvent ee) {
+ return ee.exception();
+ }
+ return Optional.empty();
+ }
}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java b/api/maven-api-core/src/main/java/org/apache/maven/api/ExecutionEvent.java
similarity index 55%
copy from api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java
copy to api/maven-api-core/src/main/java/org/apache/maven/api/ExecutionEvent.java
index dda744f737..8e06b9c709 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/ExecutionEvent.java
@@ -18,19 +18,37 @@
*/
package org.apache.maven.api;
-import org.apache.maven.api.annotations.Consumer;
+import java.util.Optional;
+
import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Immutable;
import org.apache.maven.api.annotations.Nonnull;
/**
- * A listener for session events.
- * TODO: open this to other events like similar to {@code org.apache.maven.eventspy.EventSpy}
+ * A build execution event with noun-style accessors.
+ * Extends {@link Event} so existing execution listeners can consume the same event.
*
- * @since 4.0.0
+ * @see ExecutionListener
+ * @see ExecutionEventType
+ * @since 4.1.0
*/
@Experimental
-@FunctionalInterface
-@Consumer
-public interface Listener {
- void onEvent(@Nonnull Event event);
+@Immutable
+public interface ExecutionEvent extends Event {
+
+ /** {@return the kind of execution operation} */
+ @Nonnull
+ ExecutionEventType type();
+
+ /** {@return the current project, if applicable} */
+ @Nonnull
+ Optional<Project> project();
+
+ /** {@return the current mojo execution, if applicable} */
+ @Nonnull
+ Optional<MojoExecution> mojoExecution();
+
+ /** {@return the failure associated with this event, if any} */
+ @Nonnull
+ Optional<Exception> exception();
}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java b/api/maven-api-core/src/main/java/org/apache/maven/api/ExecutionEventType.java
similarity index 66%
copy from api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java
copy to api/maven-api-core/src/main/java/org/apache/maven/api/ExecutionEventType.java
index dda744f737..b5727733f3 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/ExecutionEventType.java
@@ -18,19 +18,30 @@
*/
package org.apache.maven.api;
-import org.apache.maven.api.annotations.Consumer;
import org.apache.maven.api.annotations.Experimental;
-import org.apache.maven.api.annotations.Nonnull;
/**
- * A listener for session events.
- * TODO: open this to other events like similar to {@code org.apache.maven.eventspy.EventSpy}
+ * The possible types of build execution events.
*
- * @since 4.0.0
+ * @since 4.1.0
*/
@Experimental
-@FunctionalInterface
-@Consumer
-public interface Listener {
- void onEvent(@Nonnull Event event);
+public enum ExecutionEventType {
+ PROJECT_DISCOVERY_STARTED,
+ SESSION_STARTED,
+ SESSION_ENDED,
+ PROJECT_SKIPPED,
+ PROJECT_STARTED,
+ PROJECT_SUCCEEDED,
+ PROJECT_FAILED,
+ MOJO_SKIPPED,
+ MOJO_STARTED,
+ MOJO_SUCCEEDED,
+ MOJO_FAILED,
+ FORK_STARTED,
+ FORK_SUCCEEDED,
+ FORK_FAILED,
+ FORKED_PROJECT_STARTED,
+ FORKED_PROJECT_SUCCEEDED,
+ FORKED_PROJECT_FAILED,
}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/ExecutionListener.java b/api/maven-api-core/src/main/java/org/apache/maven/api/ExecutionListener.java
new file mode 100644
index 0000000000..cd5797963d
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/ExecutionListener.java
@@ -0,0 +1,192 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.api;
+
+import org.apache.maven.api.annotations.Consumer;
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+
+/**
+ * Receives build execution events through typed callbacks.
+ * Each method corresponds to a specific {@link ExecutionEventType} and has a no-op default implementation,
+ * so implementations only need to override the methods they care about.
+ * <p>
+ * Register an {@code ExecutionListener} via {@link Session#registerListener(Listener)}.
+ * Implementations must support concurrent notification during parallel builds.
+ *
+ * @see ExecutionEvent
+ * @see ExecutionEventType
+ * @since 4.1.0
+ */
+@Experimental
+@Consumer
+public interface ExecutionListener extends Listener {
+
+ /**
+ * Dispatches the given event to the appropriate typed callback.
+ * This default implementation routes {@link ExecutionEvent}s to the matching method
+ * and ignores other event types.
+ *
+ * @param event the event to dispatch
+ */
+ @Override
+ default void onEvent(@Nonnull Event event) {
+ if (event instanceof ExecutionEvent ee) {
+ switch (ee.type()) {
+ case PROJECT_DISCOVERY_STARTED -> projectDiscoveryStarted(ee);
+ case SESSION_STARTED -> sessionStarted(ee);
+ case SESSION_ENDED -> sessionEnded(ee);
+ case PROJECT_SKIPPED -> projectSkipped(ee);
+ case PROJECT_STARTED -> projectStarted(ee);
+ case PROJECT_SUCCEEDED -> projectSucceeded(ee);
+ case PROJECT_FAILED -> projectFailed(ee);
+ case MOJO_SKIPPED -> mojoSkipped(ee);
+ case MOJO_STARTED -> mojoStarted(ee);
+ case MOJO_SUCCEEDED -> mojoSucceeded(ee);
+ case MOJO_FAILED -> mojoFailed(ee);
+ case FORK_STARTED -> forkStarted(ee);
+ case FORK_SUCCEEDED -> forkSucceeded(ee);
+ case FORK_FAILED -> forkFailed(ee);
+ case FORKED_PROJECT_STARTED -> forkedProjectStarted(ee);
+ case FORKED_PROJECT_SUCCEEDED -> forkedProjectSucceeded(ee);
+ case FORKED_PROJECT_FAILED -> forkedProjectFailed(ee);
+ default -> {}
+ }
+ }
+ }
+
+ /**
+ * Called when project discovery has started.
+ *
+ * @param event the execution details, including the session and any project, mojo, or failure
+ */
+ default void projectDiscoveryStarted(@Nonnull ExecutionEvent event) {}
+
+ /**
+ * Called when the build session starts.
+ *
+ * @param event the execution details, including the session and any project, mojo, or failure
+ */
+ default void sessionStarted(@Nonnull ExecutionEvent event) {}
+
+ /**
+ * Called when the build session ends.
+ *
+ * @param event the execution details, including the session and any project, mojo, or failure
+ */
+ default void sessionEnded(@Nonnull ExecutionEvent event) {}
+
+ /**
+ * Called when a project is skipped.
+ *
+ * @param event the execution details, including the session and any project, mojo, or failure
+ */
+ default void projectSkipped(@Nonnull ExecutionEvent event) {}
+
+ /**
+ * Called when a project starts.
+ *
+ * @param event the execution details, including the session and any project, mojo, or failure
+ */
+ default void projectStarted(@Nonnull ExecutionEvent event) {}
+
+ /**
+ * Called when a project completes successfully.
+ *
+ * @param event the execution details, including the session and any project, mojo, or failure
+ */
+ default void projectSucceeded(@Nonnull ExecutionEvent event) {}
+
+ /**
+ * Called when a project fails.
+ *
+ * @param event the execution details, including the session and any project, mojo, or failure
+ */
+ default void projectFailed(@Nonnull ExecutionEvent event) {}
+
+ /**
+ * Called when a mojo is skipped.
+ *
+ * @param event the execution details, including the session and any project, mojo, or failure
+ */
+ default void mojoSkipped(@Nonnull ExecutionEvent event) {}
+
+ /**
+ * Called when a mojo starts.
+ *
+ * @param event the execution details, including the session and any project, mojo, or failure
+ */
+ default void mojoStarted(@Nonnull ExecutionEvent event) {}
+
+ /**
+ * Called when a mojo completes successfully.
+ *
+ * @param event the execution details, including the session and any project, mojo, or failure
+ */
+ default void mojoSucceeded(@Nonnull ExecutionEvent event) {}
+
+ /**
+ * Called when a mojo fails.
+ *
+ * @param event the execution details, including the session and any project, mojo, or failure
+ */
+ default void mojoFailed(@Nonnull ExecutionEvent event) {}
+
+ /**
+ * Called when a forked execution starts.
+ *
+ * @param event the execution details, including the session and any project, mojo, or failure
+ */
+ default void forkStarted(@Nonnull ExecutionEvent event) {}
+
+ /**
+ * Called when a forked execution completes successfully.
+ *
+ * @param event the execution details, including the session and any project, mojo, or failure
+ */
+ default void forkSucceeded(@Nonnull ExecutionEvent event) {}
+
+ /**
+ * Called when a forked execution fails.
+ *
+ * @param event the execution details, including the session and any project, mojo, or failure
+ */
+ default void forkFailed(@Nonnull ExecutionEvent event) {}
+
+ /**
+ * Called when a forked project starts.
+ *
+ * @param event the execution details, including the session and any project, mojo, or failure
+ */
+ default void forkedProjectStarted(@Nonnull ExecutionEvent event) {}
+
+ /**
+ * Called when a forked project completes successfully.
+ *
+ * @param event the execution details, including the session and any project, mojo, or failure
+ */
+ default void forkedProjectSucceeded(@Nonnull ExecutionEvent event) {}
+
+ /**
+ * Called when a forked project fails.
+ *
+ * @param event the execution details, including the session and any project, mojo, or failure
+ */
+ default void forkedProjectFailed(@Nonnull ExecutionEvent event) {}
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java b/api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java
index dda744f737..ab11b458d5 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java
@@ -24,7 +24,21 @@
/**
* A listener for session events.
- * TODO: open this to other events like similar to {@code org.apache.maven.eventspy.EventSpy}
+ * Existing implementations and lambdas receive execution events through {@link #onEvent(Event)}.
+ * Implement {@link ExecutionListener} or {@link RepositoryListener} for typed callbacks.
+ * <p>
+ * A listener implementing both interfaces must override {@link #onEvent(Event)} to resolve
+ * their conflicting default methods. Delegate to both defaults to receive typed callbacks
+ * for both event families; each default ignores events from the other family:
+ * <pre>{@code
+ * class CombinedListener implements ExecutionListener, RepositoryListener {
+ * @Override
+ * public void onEvent(Event event) {
+ * ExecutionListener.super.onEvent(event);
+ * RepositoryListener.super.onEvent(event);
+ * }
+ * }
+ * }</pre>
*
* @since 4.0.0
*/
@@ -32,5 +46,10 @@
@FunctionalInterface
@Consumer
public interface Listener {
+ /**
+ * Receives a build execution event.
+ *
+ * @param event the execution event
+ */
void onEvent(@Nonnull Event event);
}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/RepositoryEvent.java b/api/maven-api-core/src/main/java/org/apache/maven/api/RepositoryEvent.java
new file mode 100644
index 0000000000..271f9bf5b4
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/RepositoryEvent.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.api;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Optional;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Immutable;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.services.RequestTrace;
+
+/**
+ * Describes an artifact or metadata operation performed against a repository.
+ *
+ * @since 4.1.0
+ */
+@Experimental
+@Immutable
+public interface RepositoryEvent extends Event {
+
+ /** {@return the kind of repository operation represented by this event} */
+ @Nonnull
+ RepositoryEventType type();
+
+ /** {@return the artifact involved in the event, if any} */
+ @Nonnull
+ Optional<Artifact> artifact();
+
+ /** {@return the metadata involved in the event, if any} */
+ @Nonnull
+ Optional<RepositoryMetadata> metadata();
+
+ /** {@return the local path involved in the event, if any} */
+ @Nonnull
+ Optional<Path> path();
+
+ /** {@return the repository involved in the event, if any} */
+ @Nonnull
+ Optional<Repository> repository();
+
+ /** {@return the primary failure associated with the event, if any} */
+ @Nonnull
+ Optional<Exception> exception();
+
+ /** {@return all failures associated with the event} */
+ @Nonnull
+ List<Exception> exceptions();
+
+ /** {@return the request trace associated with the event, if any} */
+ @Nonnull
+ Optional<RequestTrace> trace();
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java b/api/maven-api-core/src/main/java/org/apache/maven/api/RepositoryEventType.java
similarity index 61%
copy from api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java
copy to api/maven-api-core/src/main/java/org/apache/maven/api/RepositoryEventType.java
index dda744f737..e271f9751a 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/RepositoryEventType.java
@@ -18,19 +18,32 @@
*/
package org.apache.maven.api;
-import org.apache.maven.api.annotations.Consumer;
import org.apache.maven.api.annotations.Experimental;
-import org.apache.maven.api.annotations.Nonnull;
/**
- * A listener for session events.
- * TODO: open this to other events like similar to {@code org.apache.maven.eventspy.EventSpy}
+ * The possible types of repository events.
*
- * @since 4.0.0
+ * @since 4.1.0
*/
@Experimental
-@FunctionalInterface
-@Consumer
-public interface Listener {
- void onEvent(@Nonnull Event event);
+public enum RepositoryEventType {
+ ARTIFACT_DESCRIPTOR_INVALID,
+ ARTIFACT_DESCRIPTOR_MISSING,
+ METADATA_INVALID,
+ ARTIFACT_RESOLVING,
+ ARTIFACT_RESOLVED,
+ METADATA_RESOLVING,
+ METADATA_RESOLVED,
+ ARTIFACT_DOWNLOADING,
+ ARTIFACT_DOWNLOADED,
+ METADATA_DOWNLOADING,
+ METADATA_DOWNLOADED,
+ ARTIFACT_INSTALLING,
+ ARTIFACT_INSTALLED,
+ METADATA_INSTALLING,
+ METADATA_INSTALLED,
+ ARTIFACT_DEPLOYING,
+ ARTIFACT_DEPLOYED,
+ METADATA_DEPLOYING,
+ METADATA_DEPLOYED,
}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/RepositoryListener.java b/api/maven-api-core/src/main/java/org/apache/maven/api/RepositoryListener.java
new file mode 100644
index 0000000000..920fb301ad
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/RepositoryListener.java
@@ -0,0 +1,209 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.api;
+
+import org.apache.maven.api.annotations.Consumer;
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+
+/**
+ * Receives repository events emitted while resolving, installing, and deploying artifacts and metadata.
+ * Each method corresponds to a specific {@link RepositoryEventType} and has a no-op default implementation,
+ * so implementations only need to override the methods they care about.
+ * <p>
+ * Register a {@code RepositoryListener} via {@link Session#registerListener(Listener)}.
+ * Implementations must be thread-safe because callbacks may occur concurrently.
+ * Runtime exceptions thrown by a listener do not stop repository processing or notification of other listeners.
+ *
+ * @see RepositoryEvent
+ * @see RepositoryEventType
+ * @since 4.1.0
+ */
+@Experimental
+@Consumer
+public interface RepositoryListener extends Listener {
+
+ /**
+ * Dispatches the given event to the appropriate typed callback.
+ * This default implementation routes {@link RepositoryEvent}s to the matching method
+ * and ignores other event types.
+ *
+ * @param event the event to dispatch
+ */
+ @Override
+ default void onEvent(@Nonnull Event event) {
+ if (event instanceof RepositoryEvent re) {
+ switch (re.type()) {
+ case ARTIFACT_DESCRIPTOR_INVALID -> artifactDescriptorInvalid(re);
+ case ARTIFACT_DESCRIPTOR_MISSING -> artifactDescriptorMissing(re);
+ case METADATA_INVALID -> metadataInvalid(re);
+ case ARTIFACT_RESOLVING -> artifactResolving(re);
+ case ARTIFACT_RESOLVED -> artifactResolved(re);
+ case METADATA_RESOLVING -> metadataResolving(re);
+ case METADATA_RESOLVED -> metadataResolved(re);
+ case ARTIFACT_DOWNLOADING -> artifactDownloading(re);
+ case ARTIFACT_DOWNLOADED -> artifactDownloaded(re);
+ case METADATA_DOWNLOADING -> metadataDownloading(re);
+ case METADATA_DOWNLOADED -> metadataDownloaded(re);
+ case ARTIFACT_INSTALLING -> artifactInstalling(re);
+ case ARTIFACT_INSTALLED -> artifactInstalled(re);
+ case METADATA_INSTALLING -> metadataInstalling(re);
+ case METADATA_INSTALLED -> metadataInstalled(re);
+ case ARTIFACT_DEPLOYING -> artifactDeploying(re);
+ case ARTIFACT_DEPLOYED -> artifactDeployed(re);
+ case METADATA_DEPLOYING -> metadataDeploying(re);
+ case METADATA_DEPLOYED -> metadataDeployed(re);
+ default -> {}
+ }
+ }
+ }
+
+ /**
+ * Called when an artifact descriptor could not be parsed.
+ *
+ * @param event the event containing the artifact and parse failure
+ */
+ default void artifactDescriptorInvalid(@Nonnull RepositoryEvent event) {}
+
+ /**
+ * Called when an artifact descriptor could not be found.
+ *
+ * @param event the event containing the artifact whose descriptor is missing
+ */
+ default void artifactDescriptorMissing(@Nonnull RepositoryEvent event) {}
+
+ /**
+ * Called when repository metadata could not be parsed.
+ *
+ * @param event the event containing the metadata and parse failure
+ */
+ default void metadataInvalid(@Nonnull RepositoryEvent event) {}
+
+ /**
+ * Called before an artifact is resolved.
+ *
+ * @param event the event containing the artifact and, when applicable, its repository
+ */
+ default void artifactResolving(@Nonnull RepositoryEvent event) {}
+
+ /**
+ * Called after an artifact resolution attempt finishes.
+ *
+ * @param event the event containing the artifact and the resulting path or failure
+ */
+ default void artifactResolved(@Nonnull RepositoryEvent event) {}
+
+ /**
+ * Called before repository metadata is resolved.
+ *
+ * @param event the event containing the metadata and, when applicable, its repository
+ */
+ default void metadataResolving(@Nonnull RepositoryEvent event) {}
+
+ /**
+ * Called after a repository metadata resolution attempt finishes.
+ *
+ * @param event the event containing the metadata and the resulting path or failure
+ */
+ default void metadataResolved(@Nonnull RepositoryEvent event) {}
+
+ /**
+ * Called before an artifact is downloaded from a remote repository.
+ *
+ * @param event the event containing the artifact and remote repository
+ */
+ default void artifactDownloading(@Nonnull RepositoryEvent event) {}
+
+ /**
+ * Called after an artifact download attempt finishes.
+ *
+ * @param event the event containing the artifact, remote repository, and resulting path or failure
+ */
+ default void artifactDownloaded(@Nonnull RepositoryEvent event) {}
+
+ /**
+ * Called before repository metadata is downloaded from a remote repository.
+ *
+ * @param event the event containing the metadata and remote repository
+ */
+ default void metadataDownloading(@Nonnull RepositoryEvent event) {}
+
+ /**
+ * Called after a repository metadata download attempt finishes.
+ *
+ * @param event the event containing the metadata, remote repository, and resulting path or failure
+ */
+ default void metadataDownloaded(@Nonnull RepositoryEvent event) {}
+
+ /**
+ * Called before an artifact is installed into the local repository.
+ *
+ * @param event the event containing the artifact and source path
+ */
+ default void artifactInstalling(@Nonnull RepositoryEvent event) {}
+
+ /**
+ * Called after an artifact installation attempt finishes.
+ *
+ * @param event the event containing the artifact, local repository path, and any failure
+ */
+ default void artifactInstalled(@Nonnull RepositoryEvent event) {}
+
+ /**
+ * Called before repository metadata is installed into the local repository.
+ *
+ * @param event the event containing the metadata and source path
+ */
+ default void metadataInstalling(@Nonnull RepositoryEvent event) {}
+
+ /**
+ * Called after a repository metadata installation attempt finishes.
+ *
+ * @param event the event containing the metadata, local repository path, and any failure
+ */
+ default void metadataInstalled(@Nonnull RepositoryEvent event) {}
+
+ /**
+ * Called before an artifact is deployed to a remote repository.
+ *
+ * @param event the event containing the artifact, remote repository, and source path
+ */
+ default void artifactDeploying(@Nonnull RepositoryEvent event) {}
+
+ /**
+ * Called after an artifact deployment attempt finishes.
+ *
+ * @param event the event containing the artifact, remote repository, and any failure
+ */
+ default void artifactDeployed(@Nonnull RepositoryEvent event) {}
+
+ /**
+ * Called before repository metadata is deployed to a remote repository.
+ *
+ * @param event the event containing the metadata, remote repository, and source path
+ */
+ default void metadataDeploying(@Nonnull RepositoryEvent event) {}
+
+ /**
+ * Called after a repository metadata deployment attempt finishes.
+ *
+ * @param event the event containing the metadata, remote repository, and any failure
+ */
+ default void metadataDeployed(@Nonnull RepositoryEvent event) {}
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/RepositoryMetadata.java b/api/maven-api-core/src/main/java/org/apache/maven/api/RepositoryMetadata.java
new file mode 100644
index 0000000000..d5be0ed745
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/RepositoryMetadata.java
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.api;
+
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.Optional;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Immutable;
+import org.apache.maven.api.annotations.Nonnull;
+
+/**
+ * Metadata involved in a repository event.
+ *
+ * @since 4.1.0
+ */
+@Experimental
+@Immutable
+public interface RepositoryMetadata {
+
+ /**
+ * Describes which artifact versions a metadata item applies to.
+ */
+ enum Nature {
+ /** Metadata that applies only to release versions. */
+ RELEASE,
+
+ /** Metadata that applies only to snapshot versions. */
+ SNAPSHOT,
+
+ /** Metadata that applies to both release and snapshot versions. */
+ RELEASE_OR_SNAPSHOT,
+ }
+
+ /**
+ * {@return the group identifier, or an empty string if the metadata applies to the entire repository}
+ */
+ @Nonnull
+ String groupId();
+
+ /**
+ * {@return the artifact identifier, or an empty string if the metadata applies at group level}
+ */
+ @Nonnull
+ String artifactId();
+
+ /**
+ * {@return the version, or an empty string if the metadata applies at artifact level}
+ */
+ @Nonnull
+ String version();
+
+ /**
+ * {@return the metadata filename, such as {@code maven-metadata.xml}}
+ */
+ @Nonnull
+ String type();
+
+ /**
+ * {@return the artifact version nature to which the metadata applies}
+ */
+ @Nonnull
+ Nature nature();
+
+ /**
+ * {@return the local path of the metadata file if it has been resolved}
+ */
+ @Nonnull
+ Optional<Path> path();
+
+ /**
+ * {@return the read-only properties associated with the metadata}
+ */
+ @Nonnull
+ Map<String, String> properties();
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/Session.java b/api/maven-api-core/src/main/java/org/apache/maven/api/Session.java
index 38e608021c..0138f461b1 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/Session.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/Session.java
@@ -192,7 +192,9 @@ default Map<String, String> getEffectiveProperties() {
Session withContext(@Nonnull RequestTrace trace);
/**
- * Register the given listener which will receive all events.
+ * Registers a listener for execution events, repository events, or both.
+ * Typed listeners receive only their event-specific callbacks. Legacy listeners receive execution events.
+ * Registration is shared by Maven sessions using the same underlying repository system session.
*
* @param listener the listener to register
* @throws NullPointerException if {@code listener} is null
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java b/impl/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java
index b3bd5d95a5..45ca17114b 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java
@@ -40,6 +40,7 @@
import org.apache.maven.artifact.repository.ArtifactRepository;
import org.apache.maven.eventspy.internal.EventSpyDispatcher;
import org.apache.maven.execution.MavenExecutionRequest;
+import org.apache.maven.impl.MavenRepositoryListener;
import org.apache.maven.impl.resolver.MavenSessionBuilderSupplier;
import org.apache.maven.impl.resolver.type.TypeRegistryAdapter;
import org.apache.maven.internal.xml.XmlPlexusConfiguration;
@@ -380,7 +381,8 @@ public SessionBuilder newRepositorySessionBuilder(MavenExecutionRequest request)
sessionBuilder.setTransferListener(request.getTransferListener());
- RepositoryListener repositoryListener = eventSpyDispatcher.chainListener(new LoggingRepositoryListener(logger));
+ RepositoryListener repositoryListener = eventSpyDispatcher.chainListener(
+ new ChainedRepositoryListener(new LoggingRepositoryListener(logger), new MavenRepositoryListener()));
boolean recordReverseTree = Boolean.parseBoolean(
mergedProps.getOrDefault(Constants.MAVEN_REPO_LOCAL_RECORD_REVERSE_TREE, Boolean.FALSE.toString()));
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultEvent.java b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultEvent.java
index 7003824de6..abffffc64d 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultEvent.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultEvent.java
@@ -20,46 +20,52 @@
import java.util.Optional;
-import org.apache.maven.api.Event;
import org.apache.maven.api.EventType;
+import org.apache.maven.api.ExecutionEventType;
import org.apache.maven.api.MojoExecution;
import org.apache.maven.api.Project;
import org.apache.maven.api.Session;
import org.apache.maven.execution.ExecutionEvent;
-public class DefaultEvent implements Event {
+public class DefaultEvent implements org.apache.maven.api.ExecutionEvent {
private final InternalMavenSession session;
- private final ExecutionEvent delegate;
+ private final Project project;
+ private final MojoExecution mojoExecution;
+ private final Exception exception;
private final EventType eventType;
public DefaultEvent(InternalMavenSession session, ExecutionEvent delegate, EventType eventType) {
this.session = session;
- this.delegate = delegate;
+ this.project = session.getProject(delegate.getProject());
+ this.mojoExecution = delegate.getMojoExecution() != null
+ ? new DefaultMojoExecution(session, delegate.getMojoExecution())
+ : null;
+ this.exception = delegate.getException();
this.eventType = eventType;
}
@Override
- public EventType getType() {
- return eventType;
+ public ExecutionEventType type() {
+ return ExecutionEventType.valueOf(eventType.name());
}
@Override
- public Session getSession() {
+ public Session session() {
return session;
}
@Override
- public Optional<Project> getProject() {
- return Optional.ofNullable(session.getProject(delegate.getProject()));
+ public Optional<Project> project() {
+ return Optional.ofNullable(project);
}
@Override
- public Optional<MojoExecution> getMojoExecution() {
- return Optional.ofNullable(delegate.getMojoExecution()).map(me -> new DefaultMojoExecution(session, me));
+ public Optional<MojoExecution> mojoExecution() {
+ return Optional.ofNullable(mojoExecution);
}
@Override
- public Optional<Exception> getException() {
- return Optional.ofNullable(delegate.getException());
+ public Optional<Exception> exception() {
+ return Optional.ofNullable(exception);
}
}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/EventSpyImpl.java b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/EventSpyImpl.java
index 757b08a7ee..382a4ec100 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/EventSpyImpl.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/EventSpyImpl.java
@@ -23,18 +23,24 @@
import java.util.Collection;
-import org.apache.maven.api.Event;
import org.apache.maven.api.EventType;
import org.apache.maven.api.Listener;
import org.apache.maven.eventspy.EventSpy;
import org.apache.maven.execution.ExecutionEvent;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
/**
* Bridges between Maven3 events and Maven4 events.
+ * Each listener's {@link Listener#onEvent(org.apache.maven.api.Event)} method handles its own dispatch:
+ * legacy listeners receive the event directly, while typed listeners (such as
+ * {@link org.apache.maven.api.ExecutionListener}) route it to their specific callbacks.
*/
@Named
@Singleton
public class EventSpyImpl implements EventSpy {
+ private static final Logger LOGGER = LoggerFactory.getLogger(EventSpyImpl.class);
+
@Override
public void init(Context context) throws Exception {}
@@ -46,16 +52,24 @@ public void onEvent(Object arg) throws Exception {
EventType eventType = convert(ee.getType());
Collection<Listener> listeners = session.getListeners();
if (!listeners.isEmpty()) {
- Event event = new DefaultEvent(session, ee, eventType);
+ org.apache.maven.api.ExecutionEvent event = new DefaultEvent(session, ee, eventType);
for (Listener listener : listeners) {
- listener.onEvent(event);
+ try {
+ listener.onEvent(event);
+ } catch (RuntimeException e) {
+ LOGGER.warn(
+ "Failed to notify listener {} about {}",
+ listener.getClass().getName(),
+ eventType,
+ e);
+ }
}
}
}
}
/**
- * Simple "conversion" from Maven3 event type enum to Maven4 enum.
+ * Converts the Maven 3 execution event type to its Maven API counterpart.
*/
protected EventType convert(ExecutionEvent.Type type) {
return EventType.values()[type.ordinal()];
diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactoryTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactoryTest.java
index 111fb3548b..c6d30a13e5 100644
--- a/impl/maven-core/src/test/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactoryTest.java
+++ b/impl/maven-core/src/test/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactoryTest.java
@@ -26,13 +26,18 @@
import java.util.List;
import java.util.Map;
import java.util.Properties;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.maven.api.RepositoryEvent;
+import org.apache.maven.api.RepositoryEventType;
+import org.apache.maven.api.RepositoryListener;
import org.apache.maven.artifact.InvalidRepositoryException;
import org.apache.maven.artifact.repository.ArtifactRepository;
import org.apache.maven.bridge.MavenRepositorySystem;
import org.apache.maven.eventspy.internal.EventSpyDispatcher;
import org.apache.maven.execution.DefaultMavenExecutionRequest;
import org.apache.maven.execution.MavenExecutionRequest;
+import org.apache.maven.impl.InternalSession;
import org.apache.maven.internal.impl.DefaultTypeRegistry;
import org.apache.maven.rtinfo.RuntimeInformation;
import org.apache.maven.settings.Server;
@@ -50,6 +55,8 @@
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrowsExactly;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
/**
* UT for {@link DefaultRepositorySystemSessionFactory}.
@@ -77,6 +84,40 @@ public class DefaultRepositorySystemSessionFactoryTest {
@Inject
protected VersionFilterBuilder versionFilterBuilder;
+ @Test
+ void exposesRepositoryEventsThroughMavenApi() throws InvalidRepositoryException {
+ DefaultRepositorySystemSessionFactory systemSessionFactory = new DefaultRepositorySystemSessionFactory(
+ aetherRepositorySystem,
+ eventSpyDispatcher,
+ information,
+ defaultTypeRegistry,
+ versionScheme,
+ Collections.emptyMap(),
+ versionFilterBuilder);
+ MavenExecutionRequest request = new DefaultMavenExecutionRequest();
+ request.setLocalRepository(getLocalRepository());
+ org.eclipse.aether.RepositorySystemSession resolverSession = systemSessionFactory.newRepositorySession(request);
+ InternalSession session = mock(InternalSession.class);
+ AtomicReference<RepositoryEvent> received = new AtomicReference<>();
+ RepositoryListener listener = new RepositoryListener() {
+ @Override
+ public void artifactResolved(RepositoryEvent event) {
+ received.set(event);
+ }
+ };
+ when(session.getListeners()).thenReturn(List.of(listener));
+ InternalSession.associate(resolverSession, session);
+
+ resolverSession
+ .getRepositoryListener()
+ .artifactResolved(new org.eclipse.aether.RepositoryEvent.Builder(
+ resolverSession, org.eclipse.aether.RepositoryEvent.EventType.ARTIFACT_RESOLVED)
+ .build());
+
+ assertNotNull(received.get());
+ assertEquals(RepositoryEventType.ARTIFACT_RESOLVED, received.get().type());
+ }
+
@Test
void isNoSnapshotUpdatesTest() throws InvalidRepositoryException {
DefaultRepositorySystemSessionFactory systemSessionFactory = new DefaultRepositorySystemSessionFactory(
diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/impl/EventSpyImplTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/EventSpyImplTest.java
new file mode 100644
index 0000000000..0ba976c851
--- /dev/null
+++ b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/EventSpyImplTest.java
@@ -0,0 +1,337 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.internal.impl;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.maven.api.Event;
+import org.apache.maven.api.EventType;
+import org.apache.maven.api.ExecutionEvent;
+import org.apache.maven.api.ExecutionEventType;
+import org.apache.maven.api.ExecutionListener;
+import org.apache.maven.api.Listener;
+import org.apache.maven.api.RepositoryEvent;
+import org.apache.maven.api.RepositoryListener;
+import org.apache.maven.api.Session;
+import org.apache.maven.execution.DefaultMavenExecutionRequest;
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.impl.InternalSession;
+import org.apache.maven.impl.MavenRepositoryListener;
+import org.eclipse.aether.DefaultRepositorySystemSession;
+import org.eclipse.aether.RepositorySystem;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertIterableEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class EventSpyImplTest {
+ @Test
+ void dispatchesAllExecutionCallbacksAndPreservesLegacyEvents() throws Exception {
+ TestContext context = new TestContext();
+ RecordingListener typed = new RecordingListener();
+ List<Event> legacy = new ArrayList<>();
+ context.session.registerListener(typed);
+ context.session.registerListener(legacy::add);
+
+ for (org.apache.maven.execution.ExecutionEvent.Type type :
+ org.apache.maven.execution.ExecutionEvent.Type.values()) {
+ context.execution(type);
+ }
+
+ assertEquals(List.of(ExecutionEventType.values()), typed.executions);
+ assertEquals(
+ List.of(EventType.values()), legacy.stream().map(Event::getType).toList());
+ for (Event event : legacy) {
+ ExecutionEvent typedEvent = (ExecutionEvent) event;
+ assertSame(context.session, event.getSession());
+ assertSame(event.getSession(), typedEvent.session());
+ assertEquals(event.getProject(), typedEvent.project());
+ assertEquals(event.getMojoExecution(), typedEvent.mojoExecution());
+ assertEquals(event.getException(), typedEvent.exception());
+ }
+ }
+
+ @Test
+ void sharesOneRegistrationForBothEventFamiliesAcrossDerivedSessions() throws Exception {
+ TestContext context = new TestContext();
+ RecordingListener combined = new RecordingListener();
+ AtomicInteger legacyCalls = new AtomicInteger();
+ Listener legacy = event -> legacyCalls.incrementAndGet();
+ AtomicInteger repositoryOnlyCalls = new AtomicInteger();
+ RepositoryListener repositoryOnly = new RepositoryListener() {
+ @Override
+ public void artifactResolving(RepositoryEvent event) {
+ repositoryOnlyCalls.incrementAndGet();
+ }
+ };
+ context.session.registerListener(combined);
+ context.session.registerListener(legacy);
+ context.session.registerListener(repositoryOnly);
+ Session derived = context.session.withRemoteRepositories(List.of());
+ assertIterableEquals(context.session.getListeners(), derived.getListeners());
+ assertThrows(
+ UnsupportedOperationException.class,
+ () -> derived.getListeners().clear());
+
+ context.execution(org.apache.maven.execution.ExecutionEvent.Type.SessionStarted);
+ context.repository();
+ assertEquals(List.of(ExecutionEventType.SESSION_STARTED), combined.executions);
+ assertEquals(1, combined.repositoryCalls);
+ assertEquals(1, repositoryOnlyCalls.get());
+ assertEquals(1, legacyCalls.get());
+
+ derived.unregisterListener(combined);
+ derived.unregisterListener(legacy);
+ derived.unregisterListener(repositoryOnly);
+ context.execution(org.apache.maven.execution.ExecutionEvent.Type.SessionStarted);
+ context.repository();
+ assertTrue(context.session.getListeners().isEmpty());
+ assertEquals(1, combined.repositoryCalls);
+ assertEquals(1, legacyCalls.get());
+ }
+
+ @Test
+ void isolatesExecutionListenerFailures() throws Exception {
+ TestContext context = new TestContext();
+ context.session.registerListener(new ExecutionListener() {
+ @Override
+ public void sessionStarted(ExecutionEvent event) {
+ throw new IllegalStateException("listener failure");
+ }
+ });
+ AtomicInteger calls = new AtomicInteger();
+ context.session.registerListener(event -> calls.incrementAndGet());
+ context.execution(org.apache.maven.execution.ExecutionEvent.Type.SessionStarted);
+ assertEquals(1, calls.get());
+ }
+
+ @Test
+ void rejectsNullRegistrationAndRemoval() {
+ TestContext context = new TestContext();
+ assertThrows(NullPointerException.class, () -> context.session.registerListener(null));
+ assertThrows(NullPointerException.class, () -> context.session.unregisterListener(null));
+ }
+
+ @Test
+ void supportsCombinedListenerWithoutLegacyOverride() throws Exception {
+ TestContext context = new TestContext();
+ class CombinedListener implements ExecutionListener, RepositoryListener {
+ int executionCalls;
+ int repositoryCalls;
+
+ @Override
+ public void onEvent(Event event) {
+ ExecutionListener.super.onEvent(event);
+ RepositoryListener.super.onEvent(event);
+ }
+
+ @Override
+ public void sessionStarted(ExecutionEvent event) {
+ executionCalls++;
+ }
+
+ @Override
+ public void artifactResolving(RepositoryEvent event) {
+ repositoryCalls++;
+ }
+ }
+ CombinedListener listener = new CombinedListener();
+ context.session.registerListener(listener);
+ context.execution(org.apache.maven.execution.ExecutionEvent.Type.SessionStarted);
+ context.repository();
+ assertEquals(1, listener.executionCalls);
+ assertEquals(1, listener.repositoryCalls);
+ }
+
+ @Test
+ void supportsConcurrentRegistrationAndRemovalThroughDerivedSession() {
+ TestContext context = new TestContext();
+ Session derived = context.session.withRemoteRepositories(List.of());
+ List<Listener> listeners = java.util.stream.IntStream.range(0, 100)
+ .mapToObj(i -> (Listener) new ExecutionListener() {})
+ .toList();
+ listeners.parallelStream().forEach(context.session::registerListener);
+ assertEquals(100, derived.getListeners().size());
+ listeners.parallelStream().forEach(derived::unregisterListener);
+ assertTrue(context.session.getListeners().isEmpty());
+ }
+
+ @Test
+ void capturesExecutionDetailsWhenEventIsCreated() {
+ TestContext context = new TestContext();
+ org.apache.maven.execution.ExecutionEvent source = mock(org.apache.maven.execution.ExecutionEvent.class);
+ Exception failure = new Exception("original failure");
+ when(source.getException()).thenReturn(failure);
+ ExecutionEvent event = new DefaultEvent(context.session, source, EventType.PROJECT_FAILED);
+ when(source.getException()).thenReturn(new Exception("changed failure"));
+ assertSame(failure, event.exception().orElseThrow());
+ assertSame(failure, event.getException().orElseThrow());
+ }
+
+ private static class TestContext {
+ final DefaultRepositorySystemSession resolver = new DefaultRepositorySystemSession(h -> false);
+ final MavenSession maven = new MavenSession(null, resolver, new DefaultMavenExecutionRequest(), null);
+ final DefaultSession session =
+ new DefaultSession(maven, mock(RepositorySystem.class), List.of(), null, null, null);
+
+ TestContext() {
+ maven.setSession(session);
+ resolver.getData().set(InternalSession.class, session);
+ }
+
+ void execution(org.apache.maven.execution.ExecutionEvent.Type type) throws Exception {
+ org.apache.maven.execution.ExecutionEvent event = mock(org.apache.maven.execution.ExecutionEvent.class);
+ when(event.getSession()).thenReturn(maven);
+ when(event.getType()).thenReturn(type);
+ new EventSpyImpl().onEvent(event);
+ }
+
+ void repository() {
+ new MavenRepositoryListener()
+ .artifactResolving(new org.eclipse.aether.RepositoryEvent.Builder(
+ resolver, org.eclipse.aether.RepositoryEvent.EventType.ARTIFACT_RESOLVING)
+ .build());
+ }
+ }
+
+ private static class RecordingListener implements ExecutionListener, RepositoryListener {
+ final List<ExecutionEventType> executions = new ArrayList<>();
+ int repositoryCalls;
+
+ @Override
+ public void onEvent(Event event) {
+ ExecutionListener.super.onEvent(event);
+ RepositoryListener.super.onEvent(event);
+ }
+
+ @Override
+ public void artifactResolving(RepositoryEvent event) {
+ repositoryCalls++;
+ }
+
+ @Override
+ public void projectDiscoveryStarted(ExecutionEvent event) {
+ assertEquals(ExecutionEventType.PROJECT_DISCOVERY_STARTED, event.type());
+ executions.add(ExecutionEventType.PROJECT_DISCOVERY_STARTED);
+ }
+
+ @Override
+ public void sessionStarted(ExecutionEvent event) {
+ assertEquals(ExecutionEventType.SESSION_STARTED, event.type());
+ executions.add(ExecutionEventType.SESSION_STARTED);
+ }
+
+ @Override
+ public void sessionEnded(ExecutionEvent event) {
+ assertEquals(ExecutionEventType.SESSION_ENDED, event.type());
+ executions.add(ExecutionEventType.SESSION_ENDED);
+ }
+
+ @Override
+ public void projectSkipped(ExecutionEvent event) {
+ assertEquals(ExecutionEventType.PROJECT_SKIPPED, event.type());
+ executions.add(ExecutionEventType.PROJECT_SKIPPED);
+ }
+
+ @Override
+ public void projectStarted(ExecutionEvent event) {
+ assertEquals(ExecutionEventType.PROJECT_STARTED, event.type());
+ executions.add(ExecutionEventType.PROJECT_STARTED);
+ }
+
+ @Override
+ public void projectSucceeded(ExecutionEvent event) {
+ assertEquals(ExecutionEventType.PROJECT_SUCCEEDED, event.type());
+ executions.add(ExecutionEventType.PROJECT_SUCCEEDED);
+ }
+
+ @Override
+ public void projectFailed(ExecutionEvent event) {
+ assertEquals(ExecutionEventType.PROJECT_FAILED, event.type());
+ executions.add(ExecutionEventType.PROJECT_FAILED);
+ }
+
+ @Override
+ public void mojoSkipped(ExecutionEvent event) {
+ assertEquals(ExecutionEventType.MOJO_SKIPPED, event.type());
+ executions.add(ExecutionEventType.MOJO_SKIPPED);
+ }
+
+ @Override
+ public void mojoStarted(ExecutionEvent event) {
+ assertEquals(ExecutionEventType.MOJO_STARTED, event.type());
+ executions.add(ExecutionEventType.MOJO_STARTED);
+ }
+
+ @Override
+ public void mojoSucceeded(ExecutionEvent event) {
+ assertEquals(ExecutionEventType.MOJO_SUCCEEDED, event.type());
+ executions.add(ExecutionEventType.MOJO_SUCCEEDED);
+ }
+
+ @Override
+ public void mojoFailed(ExecutionEvent event) {
+ assertEquals(ExecutionEventType.MOJO_FAILED, event.type());
+ executions.add(ExecutionEventType.MOJO_FAILED);
+ }
+
+ @Override
+ public void forkStarted(ExecutionEvent event) {
+ assertEquals(ExecutionEventType.FORK_STARTED, event.type());
+ executions.add(ExecutionEventType.FORK_STARTED);
+ }
+
+ @Override
+ public void forkSucceeded(ExecutionEvent event) {
+ assertEquals(ExecutionEventType.FORK_SUCCEEDED, event.type());
+ executions.add(ExecutionEventType.FORK_SUCCEEDED);
+ }
+
+ @Override
+ public void forkFailed(ExecutionEvent event) {
+ assertEquals(ExecutionEventType.FORK_FAILED, event.type());
+ executions.add(ExecutionEventType.FORK_FAILED);
+ }
+
+ @Override
+ public void forkedProjectStarted(ExecutionEvent event) {
+ assertEquals(ExecutionEventType.FORKED_PROJECT_STARTED, event.type());
+ executions.add(ExecutionEventType.FORKED_PROJECT_STARTED);
+ }
+
+ @Override
+ public void forkedProjectSucceeded(ExecutionEvent event) {
+ assertEquals(ExecutionEventType.FORKED_PROJECT_SUCCEEDED, event.type());
+ executions.add(ExecutionEventType.FORKED_PROJECT_SUCCEEDED);
+ }
+
+ @Override
+ public void forkedProjectFailed(ExecutionEvent event) {
+ assertEquals(ExecutionEventType.FORKED_PROJECT_FAILED, event.type());
+ executions.add(ExecutionEventType.FORKED_PROJECT_FAILED);
+ }
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/AbstractSession.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/AbstractSession.java
index 512174a465..bdce42779a 100644
--- a/impl/maven-impl/src/main/java/org/apache/maven/impl/AbstractSession.java
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/AbstractSession.java
@@ -122,7 +122,7 @@ public abstract class AbstractSession implements InternalSession {
protected final Lookup lookup;
protected final Injector injector;
private final Map<Class<? extends Service>, Service> services = new ConcurrentHashMap<>();
- private final List<Listener> listeners = new CopyOnWriteArrayList<>();
+ private final List<Listener> listeners;
private final Cache<org.eclipse.aether.graph.DependencyNode, Node> allNodes =
Cache.newCache(Cache.ReferenceType.WEAK, "AbstractSession-Nodes");
private final Map<Class<? extends Artifact>, Cache<org.eclipse.aether.artifact.Artifact, Artifact>> allArtifacts =
@@ -159,9 +159,16 @@ protected AbstractSession(
this.repositories = getRepositories(repositories, resolverRepositories);
this.lookup = lookup;
this.injector = lookup != null ? lookup.lookupOptional(Injector.class).orElse(null) : null;
+ this.listeners = getListeners(session);
this.context = context;
}
+ @SuppressWarnings("unchecked")
+ private static List<Listener> getListeners(RepositorySystemSession session) {
+ // Both event families share the registration scope of the underlying repository system session.
+ return (List<Listener>) session.getData().computeIfAbsent(Listener.class, CopyOnWriteArrayList::new);
+ }
+
@SuppressWarnings("unchecked")
private static Stream<Class<? extends Service>> collectServiceInterfaces(Class<?> clazz) {
if (clazz == null) {
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultRepositoryEvent.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultRepositoryEvent.java
new file mode 100644
index 0000000000..325906a852
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultRepositoryEvent.java
@@ -0,0 +1,161 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.impl;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import org.apache.maven.api.Artifact;
+import org.apache.maven.api.Repository;
+import org.apache.maven.api.RepositoryEvent;
+import org.apache.maven.api.RepositoryEventType;
+import org.apache.maven.api.RepositoryMetadata;
+import org.apache.maven.api.Session;
+import org.apache.maven.api.services.RequestTrace;
+
+final class DefaultRepositoryEvent implements RepositoryEvent {
+
+ private final RepositoryEventType type;
+ private final Session session;
+ private final Artifact artifact;
+ private final RepositoryMetadata metadata;
+ private final Path path;
+ private final Repository repository;
+ private final Exception exception;
+ private final List<Exception> exceptions;
+ private final RequestTrace trace;
+
+ DefaultRepositoryEvent(InternalSession session, org.eclipse.aether.RepositoryEvent event) {
+ this.type = RepositoryEventType.valueOf(event.getType().name());
+ this.session = session;
+ this.artifact = event.getArtifact() != null ? session.getArtifact(event.getArtifact()) : null;
+ this.metadata = event.getMetadata() != null ? new DefaultRepositoryMetadata(event.getMetadata()) : null;
+ this.path = event.getPath();
+ this.repository = event.getRepository() != null
+ ? session.getRepository(event.getRepository()).orElse(null)
+ : null;
+ this.exception = event.getException();
+ this.exceptions = event.getExceptions() != null ? List.copyOf(event.getExceptions()) : List.of();
+ RequestTrace currentTrace = session.getCurrentTrace();
+ this.trace = RequestTraceHelper.toMaven(currentTrace != null ? currentTrace.context() : null, event.getTrace());
+ }
+
+ @Override
+ public RepositoryEventType type() {
+ return type;
+ }
+
+ @Override
+ public Session session() {
+ return session;
+ }
+
+ @Override
+ public Optional<Artifact> artifact() {
+ return Optional.ofNullable(artifact);
+ }
+
+ @Override
+ public Optional<RepositoryMetadata> metadata() {
+ return Optional.ofNullable(metadata);
+ }
+
+ @Override
+ public Optional<Path> path() {
+ return Optional.ofNullable(path);
+ }
+
+ @Override
+ public Optional<Repository> repository() {
+ return Optional.ofNullable(repository);
+ }
+
+ @Override
+ public Optional<Exception> exception() {
+ return Optional.ofNullable(exception);
+ }
+
+ @Override
+ public List<Exception> exceptions() {
+ return exceptions;
+ }
+
+ @Override
+ public Optional<RequestTrace> trace() {
+ return Optional.ofNullable(trace);
+ }
+
+ private static final class DefaultRepositoryMetadata implements RepositoryMetadata {
+
+ private final String groupId;
+ private final String artifactId;
+ private final String version;
+ private final String type;
+ private final Nature nature;
+ private final Path path;
+ private final Map<String, String> properties;
+
+ private DefaultRepositoryMetadata(org.eclipse.aether.metadata.Metadata metadata) {
+ this.groupId = metadata.getGroupId();
+ this.artifactId = metadata.getArtifactId();
+ this.version = metadata.getVersion();
+ this.type = metadata.getType();
+ this.nature = Nature.valueOf(metadata.getNature().name());
+ this.path = metadata.getPath();
+ this.properties = Map.copyOf(metadata.getProperties());
+ }
+
+ @Override
+ public String groupId() {
+ return groupId;
+ }
+
+ @Override
+ public String artifactId() {
+ return artifactId;
+ }
+
+ @Override
+ public String version() {
+ return version;
+ }
+
+ @Override
+ public String type() {
+ return type;
+ }
+
+ @Override
+ public Nature nature() {
+ return nature;
+ }
+
+ @Override
+ public Optional<Path> path() {
+ return Optional.ofNullable(path);
+ }
+
+ @Override
+ public Map<String, String> properties() {
+ return properties;
+ }
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/MavenRepositoryListener.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/MavenRepositoryListener.java
new file mode 100644
index 0000000000..c68c80eb41
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/MavenRepositoryListener.java
@@ -0,0 +1,160 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.impl;
+
+import java.util.Collection;
+import java.util.function.BiConsumer;
+
+import org.apache.maven.api.Listener;
+import org.apache.maven.api.RepositoryEvent;
+import org.apache.maven.api.RepositoryListener;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Bridges Maven Resolver repository events to the Maven public API.
+ */
+public final class MavenRepositoryListener extends org.eclipse.aether.AbstractRepositoryListener {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(MavenRepositoryListener.class);
+
+ private void dispatch(
+ org.eclipse.aether.RepositoryEvent event, BiConsumer<RepositoryListener, RepositoryEvent> consumer) {
+ Object associatedSession = event.getSession().getData().get(InternalSession.class);
+ if (!(associatedSession instanceof InternalSession session)) {
+ return;
+ }
+ Collection<Listener> listeners = session.getListeners();
+ if (!listeners.isEmpty()) {
+ RepositoryEvent repositoryEvent = null;
+ for (Listener registered : listeners) {
+ if (!(registered instanceof RepositoryListener listener)) {
+ continue;
+ }
+ if (repositoryEvent == null) {
+ repositoryEvent = new DefaultRepositoryEvent(session, event);
+ }
+ try {
+ consumer.accept(listener, repositoryEvent);
+ } catch (RuntimeException e) {
+ LOGGER.warn(
+ "Failed to notify repository listener {} about {}",
+ listener.getClass().getName(),
+ event.getType(),
+ e);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void artifactDescriptorInvalid(org.eclipse.aether.RepositoryEvent event) {
+ dispatch(event, RepositoryListener::artifactDescriptorInvalid);
+ }
+
+ @Override
+ public void artifactDescriptorMissing(org.eclipse.aether.RepositoryEvent event) {
+ dispatch(event, RepositoryListener::artifactDescriptorMissing);
+ }
+
+ @Override
+ public void metadataInvalid(org.eclipse.aether.RepositoryEvent event) {
+ dispatch(event, RepositoryListener::metadataInvalid);
+ }
+
+ @Override
+ public void artifactResolving(org.eclipse.aether.RepositoryEvent event) {
+ dispatch(event, RepositoryListener::artifactResolving);
+ }
+
+ @Override
+ public void artifactResolved(org.eclipse.aether.RepositoryEvent event) {
+ dispatch(event, RepositoryListener::artifactResolved);
+ }
+
+ @Override
+ public void metadataResolving(org.eclipse.aether.RepositoryEvent event) {
+ dispatch(event, RepositoryListener::metadataResolving);
+ }
+
+ @Override
+ public void metadataResolved(org.eclipse.aether.RepositoryEvent event) {
+ dispatch(event, RepositoryListener::metadataResolved);
+ }
+
+ @Override
+ public void artifactDownloading(org.eclipse.aether.RepositoryEvent event) {
+ dispatch(event, RepositoryListener::artifactDownloading);
+ }
+
+ @Override
+ public void artifactDownloaded(org.eclipse.aether.RepositoryEvent event) {
+ dispatch(event, RepositoryListener::artifactDownloaded);
+ }
+
+ @Override
+ public void metadataDownloading(org.eclipse.aether.RepositoryEvent event) {
+ dispatch(event, RepositoryListener::metadataDownloading);
+ }
+
+ @Override
+ public void metadataDownloaded(org.eclipse.aether.RepositoryEvent event) {
+ dispatch(event, RepositoryListener::metadataDownloaded);
+ }
+
+ @Override
+ public void artifactInstalling(org.eclipse.aether.RepositoryEvent event) {
+ dispatch(event, RepositoryListener::artifactInstalling);
+ }
+
+ @Override
+ public void artifactInstalled(org.eclipse.aether.RepositoryEvent event) {
+ dispatch(event, RepositoryListener::artifactInstalled);
+ }
+
+ @Override
+ public void metadataInstalling(org.eclipse.aether.RepositoryEvent event) {
+ dispatch(event, RepositoryListener::metadataInstalling);
+ }
+
+ @Override
+ public void metadataInstalled(org.eclipse.aether.RepositoryEvent event) {
+ dispatch(event, RepositoryListener::metadataInstalled);
+ }
+
+ @Override
+ public void artifactDeploying(org.eclipse.aether.RepositoryEvent event) {
+ dispatch(event, RepositoryListener::artifactDeploying);
+ }
+
+ @Override
+ public void artifactDeployed(org.eclipse.aether.RepositoryEvent event) {
+ dispatch(event, RepositoryListener::artifactDeployed);
+ }
+
+ @Override
+ public void metadataDeploying(org.eclipse.aether.RepositoryEvent event) {
+ dispatch(event, RepositoryListener::metadataDeploying);
+ }
+
+ @Override
+ public void metadataDeployed(org.eclipse.aether.RepositoryEvent event) {
+ dispatch(event, RepositoryListener::metadataDeployed);
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/standalone/ApiRunner.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/standalone/ApiRunner.java
index fa3faa2e66..98ecb75b9c 100644
--- a/impl/maven-impl/src/main/java/org/apache/maven/impl/standalone/ApiRunner.java
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/standalone/ApiRunner.java
@@ -78,6 +78,7 @@
import org.apache.maven.di.impl.InjectorImpl;
import org.apache.maven.impl.AbstractSession;
import org.apache.maven.impl.InternalSession;
+import org.apache.maven.impl.MavenRepositoryListener;
import org.apache.maven.impl.di.SessionScope;
import org.apache.maven.impl.model.DefaultInterpolator;
import org.apache.maven.impl.resolver.MavenSessionBuilderSupplier;
@@ -555,6 +556,7 @@ static Session newSession(RepositorySystem system, Lookup lookup, @Nullable Loca
// Configure the resolver session with dependency resolution machinery
MavenSessionBuilderSupplier sessionBuilderSupplier = new MavenSessionBuilderSupplier(system, false);
DefaultRepositorySystemSession rsession = new DefaultRepositorySystemSession(h -> false);
+ rsession.setRepositoryListener(new MavenRepositoryListener());
rsession.setScopeManager(new ScopeManagerImpl(Maven4ScopeManagerConfiguration.INSTANCE));
rsession.setDependencyTraverser(sessionBuilderSupplier.getDependencyTraverser());
rsession.setDependencyManager(sessionBuilderSupplier.getDependencyManager(true));
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/MavenRepositoryListenerTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/MavenRepositoryListenerTest.java
new file mode 100644
index 0000000000..1dd69aed18
--- /dev/null
+++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/MavenRepositoryListenerTest.java
@@ -0,0 +1,347 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.impl;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.IntStream;
+
+import org.apache.maven.api.Artifact;
+import org.apache.maven.api.Listener;
+import org.apache.maven.api.RemoteRepository;
+import org.apache.maven.api.RepositoryEvent;
+import org.apache.maven.api.RepositoryEventType;
+import org.apache.maven.api.RepositoryListener;
+import org.apache.maven.api.RepositoryMetadata;
+import org.apache.maven.api.Session;
+import org.eclipse.aether.DefaultRepositorySystemSession;
+import org.eclipse.aether.metadata.DefaultMetadata;
+import org.eclipse.aether.metadata.Metadata;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class MavenRepositoryListenerTest {
+
+ @Test
+ void unifiedRegistrationPreservesBuildListenerLambdas() {
+ Session session = mock(Session.class);
+
+ session.registerListener(event -> {});
+
+ verify(session).registerListener(org.mockito.ArgumentMatchers.any(Listener.class));
+ }
+
+ @Test
+ void ignoresEventsBeforeMavenSessionAssociation() {
+ DefaultRepositorySystemSession resolverSession = new DefaultRepositorySystemSession(h -> false);
+ MavenRepositoryListener bridge = new MavenRepositoryListener();
+
+ bridge.artifactResolving(new org.eclipse.aether.RepositoryEvent.Builder(
+ resolverSession, org.eclipse.aether.RepositoryEvent.EventType.ARTIFACT_RESOLVING)
+ .build());
+ }
+
+ @Test
+ void dispatchesAllRepositoryEventTypes() {
+ TestContext context = new TestContext();
+ RecordingListener listener = new RecordingListener();
+ when(context.session.getListeners()).thenReturn(List.of(listener));
+
+ for (org.eclipse.aether.RepositoryEvent.EventType type :
+ org.eclipse.aether.RepositoryEvent.EventType.values()) {
+ dispatch(
+ context.bridge,
+ new org.eclipse.aether.RepositoryEvent.Builder(context.resolverSession, type).build());
+ }
+
+ assertEquals(List.of(RepositoryEventType.values()), listener.types);
+ }
+
+ @Test
+ void convertsArtifactRepositoryFailurePathAndTrace() {
+ TestContext context = new TestContext();
+ RecordingListener listener = new RecordingListener();
+ when(context.session.getListeners()).thenReturn(List.of(listener));
+
+ org.eclipse.aether.artifact.Artifact resolverArtifact =
+ new org.eclipse.aether.artifact.DefaultArtifact("org.example:demo:jar:1.0");
+ Artifact artifact = mock(Artifact.class);
+ when(context.session.getArtifact(resolverArtifact)).thenReturn(artifact);
+ org.eclipse.aether.repository.RemoteRepository resolverRepository =
+ new org.eclipse.aether.repository.RemoteRepository.Builder("central", "default", "https://repo.example")
+ .build();
+ RemoteRepository repository = mock(RemoteRepository.class);
+ when(context.session.getRepository(resolverRepository)).thenReturn(Optional.of(repository));
+ Exception failure = new Exception("resolution failed");
+ Path path = Path.of("target", "demo.jar");
+
+ context.bridge.artifactResolved(new org.eclipse.aether.RepositoryEvent.Builder(
+ context.resolverSession, org.eclipse.aether.RepositoryEvent.EventType.ARTIFACT_RESOLVED)
+ .setArtifact(resolverArtifact)
+ .setRepository(resolverRepository)
+ .setPath(path)
+ .setException(failure)
+ .setTrace(new org.eclipse.aether.RequestTrace("request"))
+ .build());
+
+ RepositoryEvent event = listener.events.get(0);
+ assertEquals(RepositoryEventType.ARTIFACT_RESOLVED, event.type());
+ assertSame(context.session, event.session());
+ assertSame(artifact, event.artifact().orElseThrow());
+ assertSame(repository, event.repository().orElseThrow());
+ assertEquals(path, event.path().orElseThrow());
+ assertSame(failure, event.exception().orElseThrow());
+ assertEquals(List.of(failure), event.exceptions());
+ assertEquals("request", event.trace().orElseThrow().data());
+ assertTrue(event.metadata().isEmpty());
+ assertThrows(
+ UnsupportedOperationException.class, () -> event.exceptions().add(new Exception()));
+ }
+
+ @Test
+ void convertsMetadataWithoutExposingResolverMetadata() {
+ TestContext context = new TestContext();
+ RecordingListener listener = new RecordingListener();
+ when(context.session.getListeners()).thenReturn(List.of(listener));
+ Path path = Path.of("target", "maven-metadata.xml");
+ Metadata metadata = new DefaultMetadata(
+ "org.example",
+ "demo",
+ "1.0-SNAPSHOT",
+ "maven-metadata.xml",
+ Metadata.Nature.SNAPSHOT,
+ Map.of("source", "test"),
+ path);
+
+ context.bridge.metadataResolved(new org.eclipse.aether.RepositoryEvent.Builder(
+ context.resolverSession, org.eclipse.aether.RepositoryEvent.EventType.METADATA_RESOLVED)
+ .setMetadata(metadata)
+ .build());
+
+ RepositoryMetadata converted = listener.events.get(0).metadata().orElseThrow();
+ assertEquals("org.example", converted.groupId());
+ assertEquals("demo", converted.artifactId());
+ assertEquals("1.0-SNAPSHOT", converted.version());
+ assertEquals("maven-metadata.xml", converted.type());
+ assertEquals(RepositoryMetadata.Nature.SNAPSHOT, converted.nature());
+ assertEquals(path, converted.path().orElseThrow());
+ assertEquals(Map.of("source", "test"), converted.properties());
+ assertThrows(
+ UnsupportedOperationException.class,
+ () -> converted.properties().put("key", "value"));
+ }
+
+ @Test
+ void isolatesListenerFailures() {
+ TestContext context = new TestContext();
+ RepositoryListener failing = new RepositoryListener() {
+ @Override
+ public void artifactResolving(RepositoryEvent event) {
+ throw new IllegalStateException("listener failure");
+ }
+ };
+ AtomicInteger notifications = new AtomicInteger();
+ RepositoryListener succeeding = new RepositoryListener() {
+ @Override
+ public void artifactResolving(RepositoryEvent event) {
+ notifications.incrementAndGet();
+ }
+ };
+ when(context.session.getListeners()).thenReturn(List.of(failing, succeeding));
+
+ context.bridge.artifactResolving(new org.eclipse.aether.RepositoryEvent.Builder(
+ context.resolverSession, org.eclipse.aether.RepositoryEvent.EventType.ARTIFACT_RESOLVING)
+ .build());
+
+ assertEquals(1, notifications.get());
+ }
+
+ @Test
+ void supportsConcurrentDispatch() {
+ TestContext context = new TestContext();
+ AtomicInteger notifications = new AtomicInteger();
+ RepositoryListener listener = new RepositoryListener() {
+ @Override
+ public void artifactResolving(RepositoryEvent event) {
+ notifications.incrementAndGet();
+ }
+ };
+ when(context.session.getListeners()).thenReturn(List.of(listener));
+
+ IntStream.range(0, 100)
+ .parallel()
+ .forEach(i -> context.bridge.artifactResolving(new org.eclipse.aether.RepositoryEvent.Builder(
+ context.resolverSession,
+ org.eclipse.aether.RepositoryEvent.EventType.ARTIFACT_RESOLVING)
+ .build()));
+
+ assertEquals(100, notifications.get());
+ }
+
+ private static void dispatch(MavenRepositoryListener listener, org.eclipse.aether.RepositoryEvent event) {
+ switch (event.getType()) {
+ case ARTIFACT_DESCRIPTOR_INVALID -> listener.artifactDescriptorInvalid(event);
+ case ARTIFACT_DESCRIPTOR_MISSING -> listener.artifactDescriptorMissing(event);
+ case METADATA_INVALID -> listener.metadataInvalid(event);
+ case ARTIFACT_RESOLVING -> listener.artifactResolving(event);
+ case ARTIFACT_RESOLVED -> listener.artifactResolved(event);
+ case METADATA_RESOLVING -> listener.metadataResolving(event);
+ case METADATA_RESOLVED -> listener.metadataResolved(event);
+ case ARTIFACT_DOWNLOADING -> listener.artifactDownloading(event);
+ case ARTIFACT_DOWNLOADED -> listener.artifactDownloaded(event);
+ case METADATA_DOWNLOADING -> listener.metadataDownloading(event);
+ case METADATA_DOWNLOADED -> listener.metadataDownloaded(event);
+ case ARTIFACT_INSTALLING -> listener.artifactInstalling(event);
+ case ARTIFACT_INSTALLED -> listener.artifactInstalled(event);
+ case METADATA_INSTALLING -> listener.metadataInstalling(event);
+ case METADATA_INSTALLED -> listener.metadataInstalled(event);
+ case ARTIFACT_DEPLOYING -> listener.artifactDeploying(event);
+ case ARTIFACT_DEPLOYED -> listener.artifactDeployed(event);
+ case METADATA_DEPLOYING -> listener.metadataDeploying(event);
+ case METADATA_DEPLOYED -> listener.metadataDeployed(event);
+ default -> throw new IllegalArgumentException("Unknown repository event type: " + event.getType());
+ }
+ }
+
+ private static final class TestContext {
+ private final DefaultRepositorySystemSession resolverSession = new DefaultRepositorySystemSession(h -> false);
+ private final InternalSession session = mock(InternalSession.class);
+ private final MavenRepositoryListener bridge = new MavenRepositoryListener();
+
+ private TestContext() {
+ InternalSession.associate(resolverSession, session);
+ }
+ }
+
+ private static final class RecordingListener implements RepositoryListener {
+ private final List<RepositoryEventType> types = new ArrayList<>();
+ private final List<RepositoryEvent> events = new ArrayList<>();
+
+ private void record(RepositoryEvent event) {
+ types.add(event.type());
+ events.add(event);
+ }
+
+ @Override
+ public void artifactDescriptorInvalid(RepositoryEvent event) {
+ record(event);
+ }
+
+ @Override
+ public void artifactDescriptorMissing(RepositoryEvent event) {
+ record(event);
+ }
+
+ @Override
+ public void metadataInvalid(RepositoryEvent event) {
+ record(event);
+ }
+
+ @Override
+ public void artifactResolving(RepositoryEvent event) {
+ record(event);
+ }
+
+ @Override
+ public void artifactResolved(RepositoryEvent event) {
+ record(event);
+ }
+
+ @Override
+ public void metadataResolving(RepositoryEvent event) {
+ record(event);
+ }
+
+ @Override
+ public void metadataResolved(RepositoryEvent event) {
+ record(event);
+ }
+
+ @Override
+ public void artifactDownloading(RepositoryEvent event) {
+ record(event);
+ }
+
+ @Override
+ public void artifactDownloaded(RepositoryEvent event) {
+ record(event);
+ }
+
+ @Override
+ public void metadataDownloading(RepositoryEvent event) {
+ record(event);
+ }
+
+ @Override
+ public void metadataDownloaded(RepositoryEvent event) {
+ record(event);
+ }
+
+ @Override
+ public void artifactInstalling(RepositoryEvent event) {
+ record(event);
+ }
+
+ @Override
+ public void artifactInstalled(RepositoryEvent event) {
+ record(event);
+ }
+
+ @Override
+ public void metadataInstalling(RepositoryEvent event) {
+ record(event);
+ }
+
+ @Override
+ public void metadataInstalled(RepositoryEvent event) {
+ record(event);
+ }
+
+ @Override
+ public void artifactDeploying(RepositoryEvent event) {
+ record(event);
+ }
+
+ @Override
+ public void artifactDeployed(RepositoryEvent event) {
+ record(event);
+ }
+
+ @Override
+ public void metadataDeploying(RepositoryEvent event) {
+ record(event);
+ }
+
+ @Override
+ public void metadataDeployed(RepositoryEvent event) {
+ record(event);
+ }
+ }
+}
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/standalone/RequestTraceTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/standalone/RequestTraceTest.java
index adef91ddb4..05ec85e270 100644
--- a/impl/maven-impl/src/test/java/org/apache/maven/impl/standalone/RequestTraceTest.java
+++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/standalone/RequestTraceTest.java
@@ -28,6 +28,8 @@
import org.apache.maven.api.DownloadedArtifact;
import org.apache.maven.api.Node;
import org.apache.maven.api.PathScope;
+import org.apache.maven.api.RepositoryEvent;
+import org.apache.maven.api.RepositoryListener;
import org.apache.maven.api.Session;
import org.apache.maven.api.di.Named;
import org.apache.maven.api.di.Provides;
@@ -38,11 +40,6 @@
import org.apache.maven.api.services.ModelBuilderResult;
import org.apache.maven.api.services.RequestTrace;
import org.apache.maven.api.services.Sources;
-import org.apache.maven.impl.InternalSession;
-import org.apache.maven.impl.RequestTraceHelper;
-import org.eclipse.aether.AbstractRepositoryListener;
-import org.eclipse.aether.DefaultRepositorySystemSession;
-import org.eclipse.aether.RepositoryEvent;
import org.eclipse.aether.spi.connector.transport.http.ChecksumExtractor;
import org.eclipse.aether.spi.io.PathProcessor;
import org.eclipse.aether.transport.apache.ApacheTransporterFactory;
@@ -76,13 +73,13 @@ void testTraces() {
assertNotNull(result.getEffectiveModel());
List<RepositoryEvent> events = new CopyOnWriteArrayList<>();
- ((DefaultRepositorySystemSession) InternalSession.from(session).getSession())
- .setRepositoryListener(new AbstractRepositoryListener() {
- @Override
- public void artifactResolved(RepositoryEvent event) {
- events.add(event);
- }
- });
+ RepositoryListener listener = new RepositoryListener() {
+ @Override
+ public void artifactResolved(RepositoryEvent event) {
+ events.add(event);
+ }
+ };
+ session.registerListener(listener);
ArtifactCoordinates coords =
session.createArtifactCoordinates("org.apache.maven:maven-api-core:4.0.0-alpha-13");
@@ -105,14 +102,15 @@ public void artifactResolved(RepositoryEvent event) {
.next();
for (RepositoryEvent event : events) {
- org.eclipse.aether.RequestTrace trace = event.getTrace();
- assertNotNull(trace);
-
- RequestTrace rTrace = RequestTraceHelper.toMaven("collect", trace);
- assertNotNull(rTrace);
+ RequestTrace rTrace = event.trace().orElseThrow();
assertNotNull(rTrace.parent());
}
+ Session derived = session.withRemoteRepositories(session.getRemoteRepositories());
+ assertTrue(derived.getListeners().contains(listener));
+ derived.unregisterListener(listener);
+ assertTrue(session.getListeners().isEmpty());
+ assertTrue(derived.getListeners().isEmpty());
assertNotNull(node);
assertEquals(6, node.getChildren().size());
}
diff --git a/impl/maven-testing/src/main/java/org/apache/maven/testing/plugin/stubs/SessionStub.java b/impl/maven-testing/src/main/java/org/apache/maven/testing/plugin/stubs/SessionStub.java
index 41f9394c86..6299fa8abe 100644
--- a/impl/maven-testing/src/main/java/org/apache/maven/testing/plugin/stubs/SessionStub.java
+++ b/impl/maven-testing/src/main/java/org/apache/maven/testing/plugin/stubs/SessionStub.java
@@ -206,7 +206,7 @@ public void unregisterListener(Listener listener) {}
@Override
public Collection<Listener> getListeners() {
- return null;
+ return List.of();
}
@Override