This is an automated email from the ASF dual-hosted git repository.

mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 0e6ac7303c issue #7736: merge marketplace plugin metadata on same-id 
repo import (#7737)
0e6ac7303c is described below

commit 0e6ac7303c1e89f8f91909230c802158ec2e8c29
Author: Matt Casters <[email protected]>
AuthorDate: Sun Aug 2 13:46:54 2026 +0200

    issue #7736: merge marketplace plugin metadata on same-id repo import 
(#7737)
    
    When multiple hop-marketplace-repo.yaml files share a repository id (e.g.
    community Nexus), import merges plugins by G:A instead of replacing the
    list. Treat x.y.z-SNAPSHOT as the x.y.z Hop line for min/maxHopVersion
    compatibility so SNAPSHOT builds fulfill the same release requirement.
---
 plugins/misc/marketplace/README.md                 |   9 +-
 .../hop/marketplace/catalog/VersionCompat.java     |  27 +++++-
 .../marketplace/command/MarketplaceCommand.java    |   2 +-
 .../config/MarketplaceRepositoryDefinition.java    |  85 +++++++++++++++-
 .../hop/marketplace/catalog/VersionCompatTest.java |  22 +++++
 .../MarketplaceRepositoryDefinitionTest.java       | 108 +++++++++++++++++++++
 6 files changed, 247 insertions(+), 6 deletions(-)

diff --git a/plugins/misc/marketplace/README.md 
b/plugins/misc/marketplace/README.md
index cb88d010d4..89e54c4a9e 100644
--- a/plugins/misc/marketplace/README.md
+++ b/plugins/misc/marketplace/README.md
@@ -211,9 +211,16 @@ Three steps:
 - Bundled Apache optional plugins: **version = running Hop version** (not 
absolute latest on ASF)
 - Every enabled repo with **`browse: true`**: live Nexus search for `*.zip` 
(one row per artifact, latest plugin version, last updated)
 - Optional YAML `plugins:` metadata **enriches** names/categories and can set:
-  - `minHopVersion` / `maxHopVersion` — hide if this Hop is outside the range 
(e.g. datavault needs ≥ 2.18.1)
+  - `minHopVersion` / `maxHopVersion` — hide if this Hop is outside the range 
(e.g. datavault needs ≥ 2.18.1). A running `x.y.z-SNAPSHOT` counts as the 
`x.y.z` line (so `2.19.0-SNAPSHOT` fulfills `minHopVersion: 2.19.0`)
   - `version` — optional pin of the plugin artifact; otherwise latest from 
browse
 
+**Shared repository id (multi-plugin community Nexus)**
+
+Several projects may publish their own `hop-marketplace-repo.yaml` with the 
**same** `id`
+and Nexus `url` (each listing only its own plugin under `plugins:`). Import 
merges
+plugin metadata by `groupId` + `artifactId` into one hop-config entry — later 
imports
+do not wipe earlier plugins. Re-importing the same G:A refreshes that entry’s 
fields.
+
 GUI **Plugins** tab uses the same discovery as `query`.
 
 Sample: `src/main/samples/hop-marketplace-repo.community.example.yaml`
diff --git 
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/catalog/VersionCompat.java
 
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/catalog/VersionCompat.java
index 681fb3ec26..a7ef57f141 100644
--- 
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/catalog/VersionCompat.java
+++ 
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/catalog/VersionCompat.java
@@ -37,6 +37,11 @@ public final class VersionCompat {
   /**
    * Whether {@code info} may be offered on the given running Hop version. 
Blank min/max means no
    * bound on that side. Missing hop version fails closed only when a bound is 
present.
+   *
+   * <p>{@code x.y.z-SNAPSHOT} is treated as the {@code x.y.z} line for these 
bounds so developers
+   * on a SNAPSHOT build satisfy a {@code minHopVersion} of the same release 
(e.g. {@code
+   * 2.19.0-SNAPSHOT} fulfills {@code minHopVersion: 2.19.0}). Artifact 
version ordering via {@link
+   * #compare(String, String)} still ranks SNAPSHOT below the release.
    */
   public static boolean isCompatibleWithHop(OptionalPluginInfo info, String 
hopVersion) {
     if (info == null) {
@@ -50,15 +55,33 @@ public final class VersionCompat {
     if (StringUtils.isBlank(hopVersion)) {
       return false;
     }
-    if (StringUtils.isNotBlank(min) && compare(hopVersion, min) < 0) {
+    // Compatibility uses the release line; SNAPSHOT is development of that 
line.
+    String hopLine = stripSnapshotQualifier(hopVersion);
+    if (StringUtils.isNotBlank(min) && compare(hopLine, 
stripSnapshotQualifier(min)) < 0) {
       return false;
     }
-    if (StringUtils.isNotBlank(max) && compare(hopVersion, max) > 0) {
+    if (StringUtils.isNotBlank(max) && compare(hopLine, 
stripSnapshotQualifier(max)) > 0) {
       return false;
     }
     return true;
   }
 
+  /**
+   * Strip a trailing {@code -SNAPSHOT} qualifier (case-insensitive). Used for 
Hop min/max
+   * compatibility only.
+   */
+  static String stripSnapshotQualifier(String version) {
+    if (StringUtils.isBlank(version)) {
+      return version;
+    }
+    String v = version.trim();
+    String lower = v.toLowerCase(Locale.ROOT);
+    if (lower.endsWith("-snapshot")) {
+      return v.substring(0, v.length() - "-snapshot".length());
+    }
+    return v;
+  }
+
   /**
    * @return negative if a &lt; b, zero if equal, positive if a &gt; b
    */
diff --git 
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/command/MarketplaceCommand.java
 
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/command/MarketplaceCommand.java
index 88b09986b6..074d8fc04a 100644
--- 
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/command/MarketplaceCommand.java
+++ 
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/command/MarketplaceCommand.java
@@ -718,7 +718,7 @@ public class MarketplaceCommand implements Runnable, 
IHopCommand, IHasHopMetadat
       name = "import",
       description =
           "Import a hop-marketplace-repo.yaml (or URL) into hop-config.json. 
Upserts by repository"
-              + " id.")
+              + " id; merges plugin metadata when the id already exists.")
   static class RepoImportCommand extends MarketplaceSubCommand {
     @Parameters(
         index = "0",
diff --git 
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceRepositoryDefinition.java
 
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceRepositoryDefinition.java
index 625f923f0e..88f4175679 100644
--- 
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceRepositoryDefinition.java
+++ 
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceRepositoryDefinition.java
@@ -347,13 +347,94 @@ public final class MarketplaceRepositoryDefinition {
       existing.setGroupIdFilter(imported.getGroupIdFilter());
       existing.setHomepage(imported.getHomepage());
       existing.setDescription(imported.getDescription());
-      if (imported.getPlugins() != null) {
-        existing.setPlugins(new ArrayList<>(imported.getPlugins()));
+      // Multiple per-plugin YAMLs may share one repository id (e.g. community 
Nexus).
+      // Merge plugin metadata by G:A; empty import must not wipe existing 
entries.
+      if (imported.getPlugins() != null && !imported.getPlugins().isEmpty()) {
+        existing.setPlugins(mergePlugins(existing.getPlugins(), 
imported.getPlugins()));
       }
       config.ensureValidPrimary();
     }
   }
 
+  /**
+   * Merge plugin metadata lists for same-id repository import.
+   *
+   * <p>Existing entries not present in {@code imported} are kept. Imported 
entries with a matching
+   * G:A (or artifactId when groupId is missing on either side) replace the 
prior entry so re-import
+   * refreshes version and display fields. New G:A values are appended. Order: 
prior plugins first,
+   * then newly appended imports.
+   */
+  static List<OptionalPluginInfo> mergePlugins(
+      List<OptionalPluginInfo> existing, List<OptionalPluginInfo> imported) {
+    List<OptionalPluginInfo> result = new ArrayList<>();
+    if (existing != null) {
+      for (OptionalPluginInfo p : existing) {
+        if (p != null && StringUtils.isNotBlank(p.getArtifactId())) {
+          result.add(p);
+        }
+      }
+    }
+    if (imported == null || imported.isEmpty()) {
+      return result;
+    }
+    for (OptionalPluginInfo incoming : imported) {
+      if (incoming == null || StringUtils.isBlank(incoming.getArtifactId())) {
+        continue;
+      }
+      int idx = indexOfMatchingPlugin(result, incoming);
+      if (idx >= 0) {
+        result.set(idx, incoming);
+      } else {
+        result.add(incoming);
+      }
+    }
+    return result;
+  }
+
+  /**
+   * Find an existing plugin that should be updated by {@code candidate}: 
prefer groupId+artifactId,
+   * fall back to artifactId alone when either side lacks groupId.
+   */
+  static int indexOfMatchingPlugin(List<OptionalPluginInfo> plugins, 
OptionalPluginInfo candidate) {
+    if (plugins == null || candidate == null || 
StringUtils.isBlank(candidate.getArtifactId())) {
+      return -1;
+    }
+    String candArt = candidate.getArtifactId().toLowerCase(Locale.ROOT);
+    String candGa = pluginGaKey(candidate);
+    boolean candHasGroup = StringUtils.isNotBlank(candidate.getGroupId());
+
+    for (int i = 0; i < plugins.size(); i++) {
+      OptionalPluginInfo p = plugins.get(i);
+      if (p == null || StringUtils.isBlank(p.getArtifactId())) {
+        continue;
+      }
+      if (!candArt.equals(p.getArtifactId().toLowerCase(Locale.ROOT))) {
+        continue;
+      }
+      boolean pHasGroup = StringUtils.isNotBlank(p.getGroupId());
+      if (candHasGroup && pHasGroup) {
+        if (candGa.equals(pluginGaKey(p))) {
+          return i;
+        }
+        // Same artifactId, different groupId → distinct plugins
+        continue;
+      }
+      // One or both sides lack groupId: treat as the same plugin
+      return i;
+    }
+    return -1;
+  }
+
+  /** Lowercase {@code groupId:artifactId}; blank groupId becomes empty 
prefix. */
+  static String pluginGaKey(OptionalPluginInfo info) {
+    if (info == null || StringUtils.isBlank(info.getArtifactId())) {
+      return "";
+    }
+    String g =
+        StringUtils.isNotBlank(info.getGroupId()) ? 
info.getGroupId().toLowerCase(Locale.ROOT) : "";
+    return g + ":" + info.getArtifactId().toLowerCase(Locale.ROOT);
+  }
+
   private static String stringVal(Object o) {
     if (o == null) {
       return null;
diff --git 
a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/catalog/VersionCompatTest.java
 
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/catalog/VersionCompatTest.java
index e0b50568ba..8f7c002df2 100644
--- 
a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/catalog/VersionCompatTest.java
+++ 
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/catalog/VersionCompatTest.java
@@ -50,12 +50,34 @@ class VersionCompatTest {
     assertFalse(VersionCompat.isCompatibleWithHop(info, "2.17.0"));
   }
 
+  @Test
+  void minHopVersionSnapshotMatchesSameReleaseLine() {
+    OptionalPluginInfo info = new OptionalPluginInfo();
+    info.setArtifactId("hop-pentaho-reporting-output");
+    info.setMinHopVersion("2.19.0");
+    // Developers on 2.19.0-SNAPSHOT are on the 2.19.0 line
+    assertTrue(VersionCompat.isCompatibleWithHop(info, "2.19.0-SNAPSHOT"));
+    assertTrue(VersionCompat.isCompatibleWithHop(info, "2.19.0"));
+    assertTrue(VersionCompat.isCompatibleWithHop(info, "2.19.1-SNAPSHOT"));
+    assertFalse(VersionCompat.isCompatibleWithHop(info, "2.18.0-SNAPSHOT"));
+    assertFalse(VersionCompat.isCompatibleWithHop(info, "2.18.1"));
+  }
+
   @Test
   void maxHopVersionFilter() {
     OptionalPluginInfo info = new OptionalPluginInfo();
     info.setMaxHopVersion("2.19.0");
     assertTrue(VersionCompat.isCompatibleWithHop(info, "2.18.0"));
+    assertTrue(VersionCompat.isCompatibleWithHop(info, "2.19.0-SNAPSHOT"));
     assertFalse(VersionCompat.isCompatibleWithHop(info, "2.20.0"));
+    assertFalse(VersionCompat.isCompatibleWithHop(info, "2.20.0-SNAPSHOT"));
+  }
+
+  @Test
+  void compareStillRanksSnapshotBelowRelease() {
+    // Artifact ordering (latest / preferable) must keep SNAPSHOT < same-base 
release
+    assertTrue(VersionCompat.compare("2.19.0-SNAPSHOT", "2.19.0") < 0);
+    assertTrue(VersionCompat.compare("2.19.0", "2.19.0-SNAPSHOT") > 0);
   }
 
   @Test
diff --git 
a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/config/MarketplaceRepositoryDefinitionTest.java
 
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/config/MarketplaceRepositoryDefinitionTest.java
index 8c728dff59..97f8963b72 100644
--- 
a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/config/MarketplaceRepositoryDefinitionTest.java
+++ 
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/config/MarketplaceRepositoryDefinitionTest.java
@@ -24,6 +24,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.List;
+import org.apache.hop.marketplace.catalog.OptionalPluginInfo;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
@@ -112,4 +114,110 @@ class MarketplaceRepositoryDefinitionTest {
     assertTrue(saved.contains("hop-datavault"));
     assertTrue(saved.contains("minHopVersion"));
   }
+
+  @Test
+  void applyToConfigMergesPluginsFromSameIdImports() throws Exception {
+    MarketplaceConfig config = new MarketplaceConfig();
+
+    MarketplaceRepository dataVault = new MarketplaceRepository();
+    dataVault.setId("data-hopper-community");
+    dataVault.setName("Data Hopper Community Plugins");
+    
dataVault.setUrl("https://repository.data-hopper.com/repository/hop-community-plugins/";);
+    dataVault.setBrowse(true);
+    dataVault.setPlugins(List.of(plugin("org.apache.hop", "hop-datavault", 
"0.5.0", "2.18.1")));
+    MarketplaceRepositoryDefinition.applyToConfig(config, dataVault, false);
+
+    MarketplaceRepository pentaho = new MarketplaceRepository();
+    pentaho.setId("data-hopper-community");
+    pentaho.setName("Data Hopper Community Plugins");
+    
pentaho.setUrl("https://repository.data-hopper.com/repository/hop-community-plugins/";);
+    pentaho.setBrowse(true);
+    pentaho.setPlugins(
+        List.of(
+            plugin(
+                "org.projectdatahopper.hop", "hop-pentaho-reporting-output", 
"1.0.0", "2.19.0")));
+    MarketplaceRepositoryDefinition.applyToConfig(config, pentaho, false);
+
+    MarketplaceRepository found = 
config.findRepository("data-hopper-community");
+    assertEquals(2, found.getPlugins().size());
+    assertEquals("hop-datavault", found.getPlugins().get(0).getArtifactId());
+    assertEquals("hop-pentaho-reporting-output", 
found.getPlugins().get(1).getArtifactId());
+  }
+
+  @Test
+  void applyToConfigReimportUpdatesMatchingPluginKeepsOthers() throws 
Exception {
+    MarketplaceConfig config = new MarketplaceConfig();
+
+    MarketplaceRepository first = new MarketplaceRepository();
+    first.setId("community");
+    first.setUrl("https://example.com/repository/hop/";);
+    first.setPlugins(
+        List.of(
+            plugin("org.apache.hop", "hop-datavault", "0.4.0", "2.18.1"),
+            plugin("org.example", "other-plugin", "1.0.0", null)));
+    MarketplaceRepositoryDefinition.applyToConfig(config, first, false);
+
+    MarketplaceRepository update = new MarketplaceRepository();
+    update.setId("community");
+    update.setUrl("https://example.com/repository/hop/";);
+    OptionalPluginInfo refreshed = plugin("org.apache.hop", "hop-datavault", 
"0.5.0", "2.19.0");
+    refreshed.setName("Data Vault");
+    update.setPlugins(List.of(refreshed));
+    MarketplaceRepositoryDefinition.applyToConfig(config, update, false);
+
+    MarketplaceRepository found = config.findRepository("community");
+    assertEquals(2, found.getPlugins().size());
+    assertEquals("0.5.0", found.getPlugins().get(0).getVersion());
+    assertEquals("2.19.0", found.getPlugins().get(0).getMinHopVersion());
+    assertEquals("Data Vault", found.getPlugins().get(0).getName());
+    assertEquals("other-plugin", found.getPlugins().get(1).getArtifactId());
+  }
+
+  @Test
+  void applyToConfigEmptyPluginsDoesNotWipeExisting() throws Exception {
+    MarketplaceConfig config = new MarketplaceConfig();
+
+    MarketplaceRepository withPlugins = new MarketplaceRepository();
+    withPlugins.setId("community");
+    withPlugins.setUrl("https://example.com/repository/hop/";);
+    withPlugins.setPlugins(List.of(plugin("org.apache.hop", "hop-datavault", 
"0.5.0", null)));
+    MarketplaceRepositoryDefinition.applyToConfig(config, withPlugins, false);
+
+    MarketplaceRepository noPlugins = new MarketplaceRepository();
+    noPlugins.setId("community");
+    noPlugins.setUrl("https://example.com/repository/hop-v2/";);
+    noPlugins.setBrowse(true);
+    noPlugins.setPlugins(List.of());
+    MarketplaceRepositoryDefinition.applyToConfig(config, noPlugins, false);
+
+    MarketplaceRepository found = config.findRepository("community");
+    assertEquals("https://example.com/repository/hop-v2/";, found.getUrl());
+    assertTrue(found.isBrowse());
+    assertEquals(1, found.getPlugins().size());
+    assertEquals("hop-datavault", found.getPlugins().get(0).getArtifactId());
+  }
+
+  @Test
+  void mergePluginsMatchesArtifactIdWhenGroupIdMissing() {
+    OptionalPluginInfo existing = plugin(null, "hop-datavault", "0.4.0", null);
+    OptionalPluginInfo incoming = plugin("org.apache.hop", "hop-datavault", 
"0.5.0", "2.18.1");
+
+    List<OptionalPluginInfo> merged =
+        MarketplaceRepositoryDefinition.mergePlugins(List.of(existing), 
List.of(incoming));
+
+    assertEquals(1, merged.size());
+    assertEquals("0.5.0", merged.get(0).getVersion());
+    assertEquals("org.apache.hop", merged.get(0).getGroupId());
+  }
+
+  private static OptionalPluginInfo plugin(
+      String groupId, String artifactId, String version, String minHopVersion) 
{
+    OptionalPluginInfo info = new OptionalPluginInfo();
+    info.setGroupId(groupId);
+    info.setArtifactId(artifactId);
+    info.setVersion(version);
+    info.setMinHopVersion(minHopVersion);
+    info.setName(artifactId);
+    return info;
+  }
 }

Reply via email to