[gradle] Plumb through the force_version attribute (#1515)

Adds support for the force_version attribute in the Gradle resolver,
allowing users to force specific dependency versions.

Changes:
- Add forceVersion field to Artifact class
- Plumb through ConfigArtifact and ResolverConfig
- Implement in both GradleResolver and MavenResolver
- Add test for force version behavior
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/Artifact.java b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/Artifact.java
index ecd0a94..92babce 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/Artifact.java
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/Artifact.java
@@ -24,14 +24,21 @@
 
   private final Coordinates coordinates;
   private final Set<Coordinates> exclusions;
+  private final boolean forceVersion;
 
   public Artifact(Coordinates coordinates, Coordinates... exclusions) {
     this(coordinates, ImmutableSet.copyOf(exclusions));
   }
 
   public Artifact(Coordinates coordinates, Collection<Coordinates> exclusions) {
+    this(coordinates, exclusions, false);
+  }
+
+  public Artifact(
+      Coordinates coordinates, Collection<Coordinates> exclusions, boolean forceVersion) {
     this.coordinates = Objects.requireNonNull(coordinates);
     this.exclusions = ImmutableSet.copyOf(exclusions);
+    this.forceVersion = forceVersion;
   }
 
   public Coordinates getCoordinates() {
@@ -42,6 +49,10 @@
     return exclusions;
   }
 
+  public boolean isForceVersion() {
+    return forceVersion;
+  }
+
   @Override
   public String toString() {
     if (exclusions.isEmpty()) {
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/ConfigArtifact.java b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/ConfigArtifact.java
index 053a5ad..18e8175 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/ConfigArtifact.java
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/ConfigArtifact.java
@@ -28,6 +28,7 @@
   private String packaging;
   private String version;
   private Set<String> exclusions;
+  private boolean force_version;
 
   public String getGroupId() {
     return group;
@@ -56,4 +57,8 @@
 
     return exclusions.stream().map(Coordinates::new).collect(ImmutableSet.toImmutableSet());
   }
+
+  public boolean isForceVersion() {
+    return force_version;
+  }
 }
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/ResolverConfig.java b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/ResolverConfig.java
index a296702..c0cd745 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/ResolverConfig.java
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/ResolverConfig.java
@@ -177,11 +177,10 @@
                         art.getExtension(),
                         art.getClassifier(),
                         art.getVersion());
-                request.addArtifact(
-                    coords.toString(),
-                    art.getExclusions().stream()
-                        .map(c -> c.getGroupId() + ":" + c.getArtifactId())
-                        .toArray(String[]::new));
+                com.github.bazelbuild.rules_jvm_external.resolver.Artifact artifact =
+                    new com.github.bazelbuild.rules_jvm_external.resolver.Artifact(
+                        coords, art.getExclusions(), art.isForceVersion());
+                request.addArtifact(artifact);
               });
     }
 
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/gradle/GradleResolver.java b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/gradle/GradleResolver.java
index b6eea23..0034d44 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/gradle/GradleResolver.java
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/gradle/GradleResolver.java
@@ -607,10 +607,18 @@
             exclusion -> {
               exclusions.add(new ExclusionImpl(exclusion.getGroupId(), exclusion.getArtifactId()));
             });
+
+    // When force_version is true, use Gradle's !! shorthand which is equivalent to strictly()
+    // This forces the exact version and prevents transitive dependencies from overriding it
+    String version = coordinates.getVersion();
+    if (artifact.isForceVersion() && version != null && !version.isEmpty()) {
+      version = version + "!!";
+    }
+
     return new GradleDependencyImpl(
         coordinates.getGroupId(),
         coordinates.getArtifactId(),
-        coordinates.getVersion(),
+        version,
         exclusions,
         coordinates.getClassifier(),
         coordinates.getExtension());
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/maven/MavenResolver.java b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/maven/MavenResolver.java
index 24ed229..82bfe74 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/maven/MavenResolver.java
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/maven/MavenResolver.java
@@ -118,13 +118,20 @@
   private Dependency createDependency(
       com.github.bazelbuild.rules_jvm_external.resolver.Artifact source) {
     Coordinates coords = source.getCoordinates();
+
+    // When force_version is true, use Maven version range syntax [version] to force exact version
+    String version = coords.getVersion();
+    if (source.isForceVersion() && version != null && !version.isEmpty()) {
+      version = "[" + version + "]";
+    }
+
     Artifact artifact =
         new DefaultArtifact(
             coords.getGroupId(),
             coords.getArtifactId(),
             coords.getClassifier(),
             coords.getExtension(),
-            coords.getVersion());
+            version);
 
     Set<Exclusion> excluded =
         source.getExclusions().stream().map(this::createExclusion).collect(Collectors.toSet());
diff --git a/tests/com/github/bazelbuild/rules_jvm_external/resolver/ResolverTestBase.java b/tests/com/github/bazelbuild/rules_jvm_external/resolver/ResolverTestBase.java
index 3755835..14d97a5 100644
--- a/tests/com/github/bazelbuild/rules_jvm_external/resolver/ResolverTestBase.java
+++ b/tests/com/github/bazelbuild/rules_jvm_external/resolver/ResolverTestBase.java
@@ -830,6 +830,47 @@
     assertEquals(coords, resolved.nodes().iterator().next());
   }
 
+  @Test
+  public void shouldRespectForceVersionWhenResolvingConflicts() {
+    // When force_version is set on a lower version, it should win over the higher version
+    // that would normally be selected during version conflict resolution.
+    Coordinates lowerVersion = new Coordinates("com.example:forced:1.0");
+    Coordinates higherVersion = new Coordinates("com.example:forced:2.0");
+    Coordinates dependsOnLower = new Coordinates("com.example:uses-lower:1.0");
+    Coordinates dependsOnHigher = new Coordinates("com.example:uses-higher:1.0");
+
+    Path repo =
+        MavenRepo.create()
+            .add(lowerVersion)
+            .add(higherVersion)
+            .add(dependsOnLower, lowerVersion)
+            .add(dependsOnHigher, higherVersion)
+            .getPath();
+
+    // Create a request that forces the lower version using the new forceVersion capability
+    ResolutionRequest request = new ResolutionRequest().addRepository(repo.toUri());
+    request.addArtifact(dependsOnLower.toString());
+    // Force the lower version by creating an Artifact with forceVersion=true
+    Artifact forcedArtifact = new Artifact(lowerVersion, Set.of(), true);
+    request.addArtifact(forcedArtifact);
+    request.addArtifact(dependsOnHigher.toString());
+
+    // Without force_version implementation, this test will fail for Gradle resolver
+    // because it will resolve to the higher version (2.0) instead of the forced lower version (1.0)
+    Graph<Coordinates> graph = resolver.resolve(request).getResolution();
+
+    Set<Coordinates> forcedNodes =
+        graph.nodes().stream()
+            .filter(c -> "forced".equals(c.getArtifactId()))
+            .collect(Collectors.toSet());
+
+    assertEquals("Should resolve to exactly one version", 1, forcedNodes.size());
+
+    assertTrue(
+        "Should resolve to forced lower version when force_version is set",
+        forcedNodes.contains(lowerVersion));
+  }
+
   protected ResolutionRequest prepareRequestFor(URI repo, Coordinates... coordinates) {
     ResolutionRequest request = new ResolutionRequest().addRepository(repo);
     for (Coordinates coordinate : coordinates) {
diff --git a/tests/custom_maven_install/regression_testing_gradle_install.json b/tests/custom_maven_install/regression_testing_gradle_install.json
index 16a8c9a..deced35 100644
--- a/tests/custom_maven_install/regression_testing_gradle_install.json
+++ b/tests/custom_maven_install/regression_testing_gradle_install.json
@@ -526,7 +526,7 @@
     "org.jetbrains.kotlin:kotlin-stdlib-common:1.6.21": "org.jetbrains.kotlin:kotlin-stdlib-common:1.8.21",
     "org.jetbrains.kotlin:kotlin-stdlib-common:1.8.21": "org.jetbrains.kotlin:kotlin-stdlib-common:1.9.10",
     "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.21": "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10",
-    "org.jetbrains.kotlin:kotlin-stdlib:1.8.0": "org.jetbrains.kotlin:kotlin-stdlib:1.8.21",
+    "org.jetbrains.kotlin:kotlin-stdlib:1.8.0": "org.jetbrains.kotlin:kotlin-stdlib:1.9.10",
     "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1": "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4",
     "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1": "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4"
   },