From dddb6aaa956f2011cb1e0bcee9ca01c590a204f2 Mon Sep 17 00:00:00 2001
From: Didier Loiseau <didierloiseau+git@gmail.com>
Date: Thu, 26 Sep 2024 22:43:32 +0200
Subject: [PATCH] HasMavenAncestry recipe

---
 .../java/org/openrewrite/semver/Semver.java   |   8 +
 .../openrewrite/maven/HasMavenAncestry.java   | 119 ++++
 .../maven/HasMavenAncestryTest.java           | 557 ++++++++++++++++++
 3 files changed, 684 insertions(+)
 create mode 100644 rewrite-maven/src/main/java/org/openrewrite/maven/HasMavenAncestry.java
 create mode 100644 rewrite-maven/src/test/java/org/openrewrite/maven/HasMavenAncestryTest.java

diff --git a/rewrite-core/src/main/java/org/openrewrite/semver/Semver.java b/rewrite-core/src/main/java/org/openrewrite/semver/Semver.java
index 155e3107f6f1..d4d6388702a3 100644
--- a/rewrite-core/src/main/java/org/openrewrite/semver/Semver.java
+++ b/rewrite-core/src/main/java/org/openrewrite/semver/Semver.java
@@ -34,6 +34,14 @@ public static boolean isVersion(@Nullable String version) {
         return LatestRelease.RELEASE_PATTERN.matcher(version).matches();
     }
 
+    /**
+     * Validates the given version against an optional pattern
+     *
+     * @param toVersion       the version to validate. Node-style [version selectors](https://docs.openrewrite.org/reference/dependency-version-selectors) may be used.
+     * @param metadataPattern optional metadata appended to the version. Allows version selection to be extended beyond the original Node Semver semantics. So for example,
+     *                        Setting 'version' to "25-29" can be paired with a metadata pattern of "-jre" to select Guava 29.0-jre
+     * @return the validation result
+     */
     public static Validated<VersionComparator> validate(String toVersion, @Nullable String metadataPattern) {
         return Validated.<VersionComparator, String>testNone(
                 "metadataPattern",
diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/HasMavenAncestry.java b/rewrite-maven/src/main/java/org/openrewrite/maven/HasMavenAncestry.java
new file mode 100644
index 000000000000..11e21fd8189b
--- /dev/null
+++ b/rewrite-maven/src/main/java/org/openrewrite/maven/HasMavenAncestry.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2024 the original author or authors.
+ * <p>
+ * Licensed 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
+ * <p>
+ * https://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * 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.openrewrite.maven;
+
+import lombok.EqualsAndHashCode;
+import lombok.Value;
+import org.jspecify.annotations.Nullable;
+import org.openrewrite.*;
+import org.openrewrite.internal.StringUtils;
+import org.openrewrite.maven.internal.MavenPomDownloader;
+import org.openrewrite.maven.tree.MavenResolutionResult;
+import org.openrewrite.maven.tree.Parent;
+import org.openrewrite.maven.tree.ResolvedPom;
+import org.openrewrite.semver.Semver;
+import org.openrewrite.semver.VersionComparator;
+import org.openrewrite.xml.AddCommentToXmlTag;
+import org.openrewrite.xml.tree.Xml;
+
+import static java.lang.String.format;
+import static java.util.Collections.emptyList;
+import static org.openrewrite.Validated.required;
+
+@Value
+@EqualsAndHashCode(callSuper = false)
+public class HasMavenAncestry extends Recipe {
+    @Override
+    public String getDisplayName() {
+        return "Has Maven ancestry";
+    }
+
+    @Override
+    public String getDescription() {
+        return "Checks if a pom file has a given Maven ancestry among its parent poms. " +
+                "This is useful especially as a precondition for other recipes.";
+    }
+
+    @Override
+    public String getInstanceNameSuffix() {
+        return version == null ? format("%s:%s", groupId, artifactId) : format("%s:%s:%s", groupId, artifactId, version);
+    }
+
+    @Option(displayName = "Group",
+            description = "The groupId to find. The groupId is the first part of a dependency coordinate `com.google.guava:guava:VERSION`. Supports glob expressions.",
+            example = "org.springframework.*")
+    String groupId;
+
+    @Option(displayName = "Artifact ID",
+            description = "The artifactId to find. The artifactId is the second part of a dependency coordinate `com.google.guava:guava:VERSION`. Supports glob expressions.",
+            example = "spring-boot-starter-*")
+    String artifactId;
+
+    @Option(displayName = "Version",
+            description = "Match only an ancestor with the specified version. " +
+                    "Node-style [version selectors](https://docs.openrewrite.org/reference/dependency-version-selectors) may be used. " +
+                    "All versions are searched by default.",
+            example = "1.x",
+            required = false)
+    @Nullable
+    String version;
+
+    @Override
+    public Validated<Object> validate() {
+        Validated<Object> validated = super.validate().and(
+                required("groupId", groupId).or(required("artifactId", artifactId)));
+        if (version != null) {
+            return validated.and(Semver.validate(version, null));
+        }
+        return validated;
+    }
+
+    @Override
+    public TreeVisitor<?, ExecutionContext> getVisitor() {
+        return new MavenIsoVisitor<ExecutionContext>() {
+            @Nullable
+            final VersionComparator versionComparator = version != null ? Semver.validate(version, null).getValue() : null;
+
+            @Override
+            public Xml.Document visitDocument(Xml.Document document, ExecutionContext ctx) {
+                checkParents(ctx);
+                return document;
+            }
+
+            private void checkParents(ExecutionContext ctx) {
+                try {
+                    MavenResolutionResult mrr = getResolutionResult();
+                    MavenPomDownloader mpd = new MavenPomDownloader(mrr.getProjectPoms(), ctx, mrr.getMavenSettings(), mrr.getActiveProfiles());
+
+                    Parent ancestor = mrr.getPom().getRequested().getParent();
+                    while (ancestor != null) {
+                        if (StringUtils.matchesGlob(ancestor.getGroupId(), groupId) &&
+                                StringUtils.matchesGlob(ancestor.getArtifactId(), artifactId) &&
+                                (versionComparator == null || versionComparator.isValid(null, ancestor.getVersion()))) {
+                            doAfterVisit(new AddCommentToXmlTag("/project/parent", "HasMavenAncestry: " + getInstanceNameSuffix()).getVisitor());
+                            break;
+                        }
+                        ResolvedPom ancestorPom = mpd.download(ancestor.getGav(), null, null, mrr.getPom().getRepositories())
+                                .resolve(emptyList(), mpd, ctx);
+                        ancestor = ancestorPom.getRequested().getParent();
+                    }
+                } catch (MavenDownloadingException e) {
+                    throw new RuntimeException(e);
+                }
+            }
+        };
+    }
+}
diff --git a/rewrite-maven/src/test/java/org/openrewrite/maven/HasMavenAncestryTest.java b/rewrite-maven/src/test/java/org/openrewrite/maven/HasMavenAncestryTest.java
new file mode 100644
index 000000000000..ffc2aee85064
--- /dev/null
+++ b/rewrite-maven/src/test/java/org/openrewrite/maven/HasMavenAncestryTest.java
@@ -0,0 +1,557 @@
+/*
+ * Copyright 2024 the original author or authors.
+ * <p>
+ * Licensed 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
+ * <p>
+ * https://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * 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.openrewrite.maven;
+
+import org.junit.jupiter.api.Test;
+import org.openrewrite.test.RecipeSpec;
+import org.openrewrite.test.RewriteTest;
+import org.openrewrite.test.SourceSpec;
+
+import static org.openrewrite.java.Assertions.mavenProject;
+import static org.openrewrite.maven.Assertions.pomXml;
+
+class HasMavenAncestryTest implements RewriteTest {
+    @Override
+    public void defaults(RecipeSpec spec) {
+        spec.recipe(new HasMavenAncestry(
+          "org.springframework.boot",
+          "spring-boot-starter-parent",
+          null
+        ));
+    }
+
+    @Test
+    void noParent() {
+        rewriteRun(
+          pomXml(
+            //language=xml
+            """
+              <project>
+              <modelVersion>4.0.0</modelVersion>
+              
+              <groupId>com.mycompany.app</groupId>
+              <artifactId>my-app</artifactId>
+              <version>1</version>
+              </project>
+              """
+          )
+        );
+    }
+
+    @Test
+    void directParentMatches() {
+        rewriteRun(
+          pomXml(
+            //language=xml
+            """
+              <project>
+              <modelVersion>4.0.0</modelVersion>
+              
+              <parent>
+              	<groupId>org.springframework.boot</groupId>
+              	<artifactId>spring-boot-starter-parent</artifactId>
+              	<version>3.3.3</version>
+              </parent>
+              
+              <groupId>com.mycompany.app</groupId>
+              <artifactId>my-app</artifactId>
+              <version>1</version>
+              </project>
+              """,
+            //language=xml
+            """
+              <project>
+              <modelVersion>4.0.0</modelVersion>
+              
+              <parent>
+              	<!--HasMavenAncestry: org.springframework.boot:spring-boot-starter-parent-->
+              	<groupId>org.springframework.boot</groupId>
+              	<artifactId>spring-boot-starter-parent</artifactId>
+              	<version>3.3.3</version>
+              </parent>
+              
+              <groupId>com.mycompany.app</groupId>
+              <artifactId>my-app</artifactId>
+              <version>1</version>
+              </project>
+              """
+          )
+        );
+    }
+
+    @Test
+    void directParentMatchesFullGAV() {
+        rewriteRun(
+          spec -> spec.recipe(new HasMavenAncestry("org.springframework.boot", "spring-boot-starter-parent", "3.3.3")),
+          pomXml(
+            //language=xml
+            """
+              <project>
+              <modelVersion>4.0.0</modelVersion>
+              
+              <parent>
+              	<groupId>org.springframework.boot</groupId>
+              	<artifactId>spring-boot-starter-parent</artifactId>
+              	<version>3.3.3</version>
+              </parent>
+              
+              <groupId>com.mycompany.app</groupId>
+              <artifactId>my-app</artifactId>
+              <version>1</version>
+              </project>
+              """,
+            //language=xml
+            """
+              <project>
+              <modelVersion>4.0.0</modelVersion>
+              
+              <parent>
+              	<!--HasMavenAncestry: org.springframework.boot:spring-boot-starter-parent:3.3.3-->
+              	<groupId>org.springframework.boot</groupId>
+              	<artifactId>spring-boot-starter-parent</artifactId>
+              	<version>3.3.3</version>
+              </parent>
+              
+              <groupId>com.mycompany.app</groupId>
+              <artifactId>my-app</artifactId>
+              <version>1</version>
+              </project>
+              """
+          )
+        );
+    }
+
+    @Test
+    void directParentMatchesGAVMinorVersion() {
+        rewriteRun(
+          spec -> spec.recipe(new HasMavenAncestry("org.springframework.boot", "spring-boot-starter-parent", "3.3.x")),
+          pomXml(
+            //language=xml
+            """
+              <project>
+              <modelVersion>4.0.0</modelVersion>
+              
+              <parent>
+              	<groupId>org.springframework.boot</groupId>
+              	<artifactId>spring-boot-starter-parent</artifactId>
+              	<version>3.3.3</version>
+              </parent>
+              
+              <groupId>com.mycompany.app</groupId>
+              <artifactId>my-app</artifactId>
+              <version>1</version>
+              </project>
+              """,
+            //language=xml
+            """
+              <project>
+              <modelVersion>4.0.0</modelVersion>
+              
+              <parent>
+              	<!--HasMavenAncestry: org.springframework.boot:spring-boot-starter-parent:3.3.x-->
+              	<groupId>org.springframework.boot</groupId>
+              	<artifactId>spring-boot-starter-parent</artifactId>
+              	<version>3.3.3</version>
+              </parent>
+              
+              <groupId>com.mycompany.app</groupId>
+              <artifactId>my-app</artifactId>
+              <version>1</version>
+              </project>
+              """
+          )
+        );
+    }
+
+    @Test
+    void directParentMatchesGroupIdGlob() {
+        rewriteRun(
+          spec -> spec.recipe(new HasMavenAncestry("org.springframework.*", "spring-boot-starter-parent", null)),
+          pomXml(
+            //language=xml
+            """
+              <project>
+              <modelVersion>4.0.0</modelVersion>
+              
+              <parent>
+              	<groupId>org.springframework.boot</groupId>
+              	<artifactId>spring-boot-starter-parent</artifactId>
+              	<version>3.3.3</version>
+              </parent>
+              
+              <groupId>com.mycompany.app</groupId>
+              <artifactId>my-app</artifactId>
+              <version>1</version>
+              </project>
+              """,
+            //language=xml
+            """
+              <project>
+              <modelVersion>4.0.0</modelVersion>
+              
+              <parent>
+              	<!--HasMavenAncestry: org.springframework.*:spring-boot-starter-parent-->
+              	<groupId>org.springframework.boot</groupId>
+              	<artifactId>spring-boot-starter-parent</artifactId>
+              	<version>3.3.3</version>
+              </parent>
+              
+              <groupId>com.mycompany.app</groupId>
+              <artifactId>my-app</artifactId>
+              <version>1</version>
+              </project>
+              """
+          )
+        );
+    }
+
+    @Test
+    void directParentMatchesArtifactIdGlob() {
+        rewriteRun(
+          spec -> spec.recipe(new HasMavenAncestry("org.springframework.boot", "spring-*-parent", null)),
+          pomXml(
+            //language=xml
+            """
+              <project>
+              <modelVersion>4.0.0</modelVersion>
+              
+              <parent>
+              	<groupId>org.springframework.boot</groupId>
+              	<artifactId>spring-boot-starter-parent</artifactId>
+              	<version>3.3.3</version>
+              </parent>
+              
+              <groupId>com.mycompany.app</groupId>
+              <artifactId>my-app</artifactId>
+              <version>1</version>
+              </project>
+              """,
+            //language=xml
+            """
+              <project>
+              <modelVersion>4.0.0</modelVersion>
+              
+              <parent>
+              	<!--HasMavenAncestry: org.springframework.boot:spring-*-parent-->
+              	<groupId>org.springframework.boot</groupId>
+              	<artifactId>spring-boot-starter-parent</artifactId>
+              	<version>3.3.3</version>
+              </parent>
+              
+              <groupId>com.mycompany.app</groupId>
+              <artifactId>my-app</artifactId>
+              <version>1</version>
+              </project>
+              """
+          )
+        );
+    }
+
+    @Test
+    void indirectParentMatches() {
+        rewriteRun(
+          spec -> spec.recipe(new HasMavenAncestry("org.springframework.boot", "spring-boot-dependencies", null)),
+          pomXml(
+            //language=xml
+            """
+              <project>
+              <modelVersion>4.0.0</modelVersion>
+              
+              <parent>
+              	<groupId>org.springframework.boot</groupId>
+              	<artifactId>spring-boot-starter-parent</artifactId>
+              	<version>3.3.3</version>
+              </parent>
+              
+              <groupId>com.mycompany.app</groupId>
+              <artifactId>my-app</artifactId>
+              <version>1</version>
+              </project>
+              """,
+            //language=xml
+            """
+              <project>
+              <modelVersion>4.0.0</modelVersion>
+              
+              <parent>
+              	<!--HasMavenAncestry: org.springframework.boot:spring-boot-dependencies-->
+              	<groupId>org.springframework.boot</groupId>
+              	<artifactId>spring-boot-starter-parent</artifactId>
+              	<version>3.3.3</version>
+              </parent>
+              
+              <groupId>com.mycompany.app</groupId>
+              <artifactId>my-app</artifactId>
+              <version>1</version>
+              </project>
+              """
+          )
+        );
+    }
+
+    @Test
+    void indirectParentMatchesGAVPattern() {
+        rewriteRun(
+          spec -> spec.recipe(new HasMavenAncestry("*.springframework.*", "spring-*-dependencies", "3.x")),
+          pomXml(
+            //language=xml
+            """
+              <project>
+              <modelVersion>4.0.0</modelVersion>
+              
+              <parent>
+              	<groupId>org.springframework.boot</groupId>
+              	<artifactId>spring-boot-starter-parent</artifactId>
+              	<version>3.3.3</version>
+              </parent>
+              
+              <groupId>com.mycompany.app</groupId>
+              <artifactId>my-app</artifactId>
+              <version>1</version>
+              </project>
+              """,
+            //language=xml
+            """
+              <project>
+              <modelVersion>4.0.0</modelVersion>
+              
+              <parent>
+              	<!--HasMavenAncestry: *.springframework.*:spring-*-dependencies:3.x-->
+              	<groupId>org.springframework.boot</groupId>
+              	<artifactId>spring-boot-starter-parent</artifactId>
+              	<version>3.3.3</version>
+              </parent>
+              
+              <groupId>com.mycompany.app</groupId>
+              <artifactId>my-app</artifactId>
+              <version>1</version>
+              </project>
+              """
+          )
+        );
+    }
+
+    @Test
+    void multiModuleParentMatches() {
+        rewriteRun(
+          pomXml(
+            //language=xml
+            """
+              <project>
+              <modelVersion>4.0.0</modelVersion>
+              
+              <parent>
+              	<groupId>org.springframework.boot</groupId>
+              	<artifactId>spring-boot-starter-parent</artifactId>
+              	<version>3.3.3</version>
+              </parent>
+              <modules>
+              	<module>child</module>
+              </modules>
+              
+              <groupId>com.mycompany.app</groupId>
+              <artifactId>my-app</artifactId>
+              <version>1</version>
+              </project>
+              """,
+            SourceSpec::skip
+          ),
+          mavenProject("child",
+            pomXml(
+              //language=xml
+              """
+                <project>
+                <modelVersion>4.0.0</modelVersion>
+                
+                <parent>
+                	<groupId>com.mycompany.app</groupId>
+                	<artifactId>my-app</artifactId>
+                	<version>1</version>
+                </parent>
+                
+                <artifactId>child</artifactId>
+                </project>
+                """,
+              //language=xml
+              """
+                <project>
+                <modelVersion>4.0.0</modelVersion>
+                
+                <parent>
+                	<!--HasMavenAncestry: org.springframework.boot:spring-boot-starter-parent-->
+                	<groupId>com.mycompany.app</groupId>
+                	<artifactId>my-app</artifactId>
+                	<version>1</version>
+                </parent>
+                
+                <artifactId>child</artifactId>
+                </project>
+                """
+            )
+          )
+        );
+    }
+
+    @Test
+    void groupIdDoesNotMatch() {
+        rewriteRun(
+          spec -> spec.recipe(new HasMavenAncestry("org.springframework.invalid", "spring-boot-starter-parent", null)),
+          pomXml(
+            //language=xml
+            """
+              <project>
+              <modelVersion>4.0.0</modelVersion>
+              
+              <parent>
+              	<groupId>org.springframework.boot</groupId>
+              	<artifactId>spring-boot-starter-parent</artifactId>
+              	<version>3.3.3</version>
+              </parent>
+              
+              <groupId>com.mycompany.app</groupId>
+              <artifactId>my-app</artifactId>
+              <version>1</version>
+              </project>
+              """
+          )
+        );
+    }
+
+    @Test
+    void artifactIdDoesNotMatch() {
+        rewriteRun(
+          spec -> spec.recipe(new HasMavenAncestry("org.springframework.boot", "spring-boot-starter-web", null)),
+          pomXml(
+            //language=xml
+            """
+              <project>
+              <modelVersion>4.0.0</modelVersion>
+              
+              <parent>
+              	<groupId>org.springframework.boot</groupId>
+              	<artifactId>spring-boot-starter-parent</artifactId>
+              	<version>3.3.3</version>
+              </parent>
+              
+              <groupId>com.mycompany.app</groupId>
+              <artifactId>my-app</artifactId>
+              <version>1</version>
+              </project>
+              """
+          )
+        );
+    }
+
+    @Test
+    void versionDoesNotMatch() {
+        rewriteRun(
+          spec -> spec.recipe(new HasMavenAncestry("org.springframework.boot", "spring-boot-starter-parent", "3.3.4")),
+          pomXml(
+            //language=xml
+            """
+              <project>
+              <modelVersion>4.0.0</modelVersion>
+              
+              <parent>
+              	<groupId>org.springframework.boot</groupId>
+              	<artifactId>spring-boot-starter-parent</artifactId>
+              	<version>3.3.3</version>
+              </parent>
+              
+              <groupId>com.mycompany.app</groupId>
+              <artifactId>my-app</artifactId>
+              <version>1</version>
+              </project>
+              """
+          )
+        );
+    }
+
+    @Test
+    void minorVersionDoesNotMatch() {
+        rewriteRun(
+          spec -> spec.recipe(new HasMavenAncestry("org.springframework.boot", "spring-boot-starter-parent", "3.3.x")),
+          pomXml(
+            //language=xml
+            """
+              <project>
+              <modelVersion>4.0.0</modelVersion>
+              
+              <parent>
+              	<groupId>org.springframework.boot</groupId>
+              	<artifactId>spring-boot-starter-parent</artifactId>
+              	<version>3.0.5</version>
+              </parent>
+              
+              <groupId>com.mycompany.app</groupId>
+              <artifactId>my-app</artifactId>
+              <version>1</version>
+              </project>
+              """
+          )
+        );
+    }
+
+    @Test
+    void doesNotMatchGroupIdGlob() {
+        rewriteRun(
+          spec -> spec.recipe(new HasMavenAncestry("org.invalid.*", "spring-boot-starter-parent", null)),
+          pomXml(
+            //language=xml
+            """
+              <project>
+              <modelVersion>4.0.0</modelVersion>
+              
+              <parent>
+              	<groupId>org.springframework.boot</groupId>
+              	<artifactId>spring-boot-starter-parent</artifactId>
+              	<version>3.3.3</version>
+              </parent>
+              
+              <groupId>com.mycompany.app</groupId>
+              <artifactId>my-app</artifactId>
+              <version>1</version>
+              </project>
+              """
+          )
+        );
+    }
+
+    @Test
+    void doesNotMatchArtifactIdGlob() {
+        rewriteRun(
+          spec -> spec.recipe(new HasMavenAncestry("org.springframework.boot", "spring-boot-*-web", null)),
+          pomXml(
+            //language=xml
+            """
+              <project>
+              <modelVersion>4.0.0</modelVersion>
+              
+              <parent>
+              	<groupId>org.springframework.boot</groupId>
+              	<artifactId>spring-boot-starter-parent</artifactId>
+              	<version>3.3.3</version>
+              </parent>
+              
+              <groupId>com.mycompany.app</groupId>
+              <artifactId>my-app</artifactId>
+              <version>1</version>
+              </project>
+              """
+          )
+        );
+    }
+}
\ No newline at end of file