{
+
+ public SemVerComparator() {}
+
+ @Override
+ public Semver parse(String versionString) {
+ Objects.requireNonNull(versionString, "versionString cannot be null");
+ Semver semver = Semver.parse(versionString);
+ if (semver == null) {
+ throw new IllegalArgumentException(String.format("invalid semver: \"%s\"", versionString));
+ }
+ return semver;
+ }
+
+ @Override
+ public boolean compare(Semver currentVersion, Semver minCodeVersion) {
+ Objects.requireNonNull(currentVersion, "currentVersion cannot be null");
+ Objects.requireNonNull(minCodeVersion, "minCodeVersion cannot be null");
+ return currentVersion.isGreaterThanOrEqualTo(minCodeVersion);
+ }
+}
diff --git a/hooks/codereadiness/src/main/java/dev/openfeature/contrib/hooks/codereadiness/VersionComparator.java b/hooks/codereadiness/src/main/java/dev/openfeature/contrib/hooks/codereadiness/VersionComparator.java
new file mode 100644
index 000000000..c7ce6c20a
--- /dev/null
+++ b/hooks/codereadiness/src/main/java/dev/openfeature/contrib/hooks/codereadiness/VersionComparator.java
@@ -0,0 +1,24 @@
+package dev.openfeature.contrib.hooks.codereadiness;
+
+/**
+ * Defines the contract for parsing version strings and comparing code version objects
+ * (current and minimum required version) according to specified rules. Used by {@link CodeReadinessHook}.
+ *
+ * The {@link CodeReadinessHook} uses {@link SemVerComparator} by default for standard Semantic
+ * Versioning, but developers may implement this interface to support custom or non-standard
+ * versioning schemes.
+ *
+ * @param The domain object type representing a parsed version (e.g., Semver, LocalDate, Integer).
+ */
+public interface VersionComparator {
+
+ /**
+ * Parse version string into domain object.
+ */
+ T parse(String versionString) throws Exception;
+
+ /**
+ * Compare current version with required version.
+ */
+ boolean compare(T currentVersion, T minCodeVersion) throws Exception;
+}
diff --git a/hooks/codereadiness/src/test/java/dev/openfeature/contrib/hooks/codereadiness/CodeReadinessHookTest.java b/hooks/codereadiness/src/test/java/dev/openfeature/contrib/hooks/codereadiness/CodeReadinessHookTest.java
new file mode 100644
index 000000000..c5c4372f6
--- /dev/null
+++ b/hooks/codereadiness/src/test/java/dev/openfeature/contrib/hooks/codereadiness/CodeReadinessHookTest.java
@@ -0,0 +1,299 @@
+package dev.openfeature.contrib.hooks.codereadiness;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import dev.openfeature.sdk.FlagEvaluationDetails;
+import dev.openfeature.sdk.HookContext;
+import dev.openfeature.sdk.ImmutableMetadata;
+import dev.openfeature.sdk.exceptions.GeneralError;
+import java.util.Collections;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+class CodeReadinessHookTest {
+
+ private final HookContext hookContext = mock(HookContext.class);
+
+ @BeforeEach
+ void setUp() {
+ when(hookContext.getFlagKey()).thenReturn("testFlag");
+ }
+
+ @Test
+ @DisplayName("Should pass when current version is equal or greater than required metadata version")
+ void testValidVersionPasses() {
+ CodeReadinessHook hook = CodeReadinessHook.builder("1.5.0").build();
+ FlagEvaluationDetails details = createDetailsWithMetadata("minCodeVersion", "1.2.0");
+
+ assertThatCode(() -> hook.after(hookContext, details, Collections.emptyMap()))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("Should throw GeneralError when current version is less than required metadata version")
+ void testInvalidVersionThrowsGeneralError() {
+ CodeReadinessHook hook = CodeReadinessHook.builder("1.0.0").build();
+ FlagEvaluationDetails details = createDetailsWithMetadata("minCodeVersion", "1.2.0");
+
+ assertThatThrownBy(() -> hook.after(hookContext, details, Collections.emptyMap()))
+ .isInstanceOf(GeneralError.class)
+ .hasMessage("current version: \"1.0.0\" required minimum version: \"1.2.0\" check failed");
+ }
+
+ @Test
+ @DisplayName("Should ignore missing metadata when strictValidation is false")
+ void testMissingMetadataIgnoredWhenValidationNotRequired() {
+ CodeReadinessHook hook =
+ CodeReadinessHook.builder("1.0.0").strictValidation(false).build();
+
+ assertThatCode(() -> hook.after(hookContext, null, Collections.emptyMap()))
+ .doesNotThrowAnyException();
+
+ FlagEvaluationDetails emptyMetadataDetails = FlagEvaluationDetails.builder()
+ .flagMetadata(ImmutableMetadata.builder().build())
+ .build();
+ assertThatCode(() -> hook.after(hookContext, emptyMetadataDetails, Collections.emptyMap()))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("Should throw GeneralError when metadata is missing and strictValidation is true")
+ void testMissingMetadataThrowsWhenStrictValidation() {
+ CodeReadinessHook hook =
+ CodeReadinessHook.builder("1.0.0").strictValidation(true).build();
+
+ assertThatThrownBy(() -> hook.after(hookContext, null, Collections.emptyMap()))
+ .isInstanceOf(GeneralError.class)
+ .hasMessage("flag metadata is null for flag \"testFlag\"");
+
+ FlagEvaluationDetails emptyMetadataDetails = FlagEvaluationDetails.builder()
+ .flagMetadata(ImmutableMetadata.builder().build())
+ .build();
+ assertThatThrownBy(() -> hook.after(hookContext, emptyMetadataDetails, Collections.emptyMap()))
+ .isInstanceOf(GeneralError.class)
+ .hasMessage("flag metadata is null for flag \"testFlag\"");
+ }
+
+ @Test
+ @DisplayName("Should ignore missing minCodeVersion key when strictValidation is false")
+ void testMissingKeyIgnoredWhenStrictValidationNotRequired() {
+ CodeReadinessHook hook =
+ CodeReadinessHook.builder("1.0.0").strictValidation(false).build();
+ FlagEvaluationDetails details = createDetailsWithMetadata("otherKey", "1.0.0");
+
+ assertThatCode(() -> hook.after(hookContext, details, Collections.emptyMap()))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("Should throw GeneralError when minCodeVersion key is missing and strictValidation is true")
+ void testMissingKeyThrowsWhenStrictValidation() {
+ CodeReadinessHook hook =
+ CodeReadinessHook.builder("1.0.0").strictValidation(true).build();
+ FlagEvaluationDetails details = createDetailsWithMetadata("otherKey", "1.0.0");
+
+ assertThatThrownBy(() -> hook.after(hookContext, details, Collections.emptyMap()))
+ .isInstanceOf(GeneralError.class)
+ .hasMessage("key \"minCodeVersion\" missing or empty in flag's \"testFlag\" metadata");
+ }
+
+ @Test
+ @DisplayName("Should use custom metadataMinVerKey when specified")
+ void testCustomMetadataMinVerKey() {
+ CodeReadinessHook hook = CodeReadinessHook.builder("2.0.0")
+ .metadataMinVerKey("customMinVersion")
+ .strictValidation(true)
+ .build();
+ FlagEvaluationDetails details = createDetailsWithMetadata("customMinVersion", "1.5.0");
+
+ assertThatCode(() -> hook.after(hookContext, details, Collections.emptyMap()))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("Should throw GeneralError when metadata value is not a string and strictValidation is true")
+ void testNonStringMetadataValueThrows() {
+ CodeReadinessHook hook =
+ CodeReadinessHook.builder("1.0.0").strictValidation(true).build();
+ FlagEvaluationDetails details = createDetailsWithMetadata("minCodeVersion", true);
+
+ assertThatThrownBy(() -> hook.after(hookContext, details, Collections.emptyMap()))
+ .isInstanceOf(GeneralError.class)
+ .hasMessage("key \"minCodeVersion\" missing or empty in flag's \"testFlag\" metadata");
+ }
+
+ @Test
+ @DisplayName("Should ignore non string metadata value when strictValidation is false")
+ void testNonStringMetadataValueIgnores() {
+ CodeReadinessHook hook =
+ CodeReadinessHook.builder("1.0.0").strictValidation(false).build();
+ FlagEvaluationDetails details = createDetailsWithMetadata("minCodeVersion", true);
+
+ assertThatCode(() -> hook.after(hookContext, details, Collections.emptyMap()))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("Should ignore empty minCodeVersion value when strictValidation is false")
+ void testEmptyVersionStringIgnoredWhenStrictValidationNotRequired() {
+ CodeReadinessHook hook =
+ CodeReadinessHook.builder("1.0.0").strictValidation(false).build();
+ FlagEvaluationDetails details = createDetailsWithMetadata("minCodeVersion", "");
+
+ assertThatCode(() -> hook.after(hookContext, details, Collections.emptyMap()))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("Should throw GeneralError when minCodeVersion string is empty and strictValidation is true")
+ void testEmptyVersionStringThrowsWhenStrictValidation() {
+ CodeReadinessHook hook =
+ CodeReadinessHook.builder("1.0.0").strictValidation(true).build();
+ FlagEvaluationDetails details = createDetailsWithMetadata("minCodeVersion", "");
+
+ assertThatThrownBy(() -> hook.after(hookContext, details, Collections.emptyMap()))
+ .isInstanceOf(GeneralError.class)
+ .hasMessage("key \"minCodeVersion\" missing or empty in flag's \"testFlag\" metadata");
+ }
+
+ @Test
+ @DisplayName("Should throw NullPointerException when building hook with null arguments")
+ void testNullArgumentsThrowNpeAtBuildTime() {
+ assertThatThrownBy(() -> CodeReadinessHook.builder(null).build())
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("codereadiness: currentVersion cannot be null");
+
+ assertThatThrownBy(() ->
+ CodeReadinessHook.builder("1.0.0").comparator(null).build())
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("codereadiness: comparator cannot be null");
+
+ assertThatThrownBy(() -> CodeReadinessHook.builder("1.0.0")
+ .metadataMinVerKey(null)
+ .build())
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("codereadiness: metadataMinVerKey cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should use custom comparator when configured")
+ void testCustomComparator() throws Exception {
+ CodeReadinessHook hook = CodeReadinessHook.builder("10.0.0")
+ .comparator(new VersionComparator() {
+ @Override
+ public String parse(String s) {
+ return s;
+ }
+
+ @Override
+ public boolean compare(String c, String m) {
+ return false;
+ }
+ })
+ .build();
+ FlagEvaluationDetails details = createDetailsWithMetadata("minCodeVersion", "1.0.0");
+
+ assertThatThrownBy(() -> hook.after(hookContext, details, Collections.emptyMap()))
+ .isInstanceOf(GeneralError.class)
+ .hasMessage("current version: \"10.0.0\" required minimum version: \"1.0.0\" check failed");
+ }
+
+ @Test
+ @DisplayName("Should wrap exception thrown by comparator into GeneralError")
+ void testComparatorExceptionWrappedInGeneralErrorStrictValidation() throws Exception {
+ CodeReadinessHook hook = CodeReadinessHook.builder("1.0.0")
+ .strictValidation(true)
+ .comparator(new VersionComparator() {
+ @Override
+ public String parse(String s) {
+ return s;
+ }
+
+ @Override
+ public boolean compare(String c, String m) {
+ throw new RuntimeException("comparator error");
+ }
+ })
+ .build();
+ FlagEvaluationDetails details = createDetailsWithMetadata("minCodeVersion", "1.0.0");
+
+ assertThatThrownBy(() -> hook.after(hookContext, details, Collections.emptyMap()))
+ .isInstanceOf(GeneralError.class)
+ .hasMessage(
+ "current version: \"1.0.0\" required minimum version: \"1.0.0\" check failed: comparator error")
+ .hasCauseInstanceOf(RuntimeException.class);
+ }
+
+ @Test
+ @DisplayName("Should skip validation when exception thrown by comparator and strictValidation is false")
+ void testComparatorExceptionNonStrictValidation() throws Exception {
+ CodeReadinessHook hook = CodeReadinessHook.builder("1.0.0")
+ .strictValidation(false)
+ .comparator(new VersionComparator() {
+ @Override
+ public String parse(String s) {
+ return s;
+ }
+
+ @Override
+ public boolean compare(String c, String m) {
+ throw new RuntimeException("comparator error");
+ }
+ })
+ .build();
+ FlagEvaluationDetails details = createDetailsWithMetadata("minCodeVersion", "1.0.0");
+
+ assertThatCode(() -> hook.after(hookContext, details, Collections.emptyMap()))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("Should wrap exception thrown during comparator initialization into GeneralError")
+ void testComparatorInitExceptionWrappedInGeneralError() {
+ assertThatThrownBy(() -> CodeReadinessHook.builder("invalid-version").build())
+ .isInstanceOf(GeneralError.class)
+ .hasMessageContaining("initialization failed");
+ }
+
+ @Test
+ @DisplayName("Should support generic custom comparator with domain object parsing")
+ void testGenericCustomDomainComparator() {
+ VersionComparator intComparator = new VersionComparator() {
+ @Override
+ public Integer parse(String versionString) {
+ return Integer.parseInt(versionString);
+ }
+
+ @Override
+ public boolean compare(Integer currentVersion, Integer minCodeVersion) {
+ return currentVersion >= minCodeVersion;
+ }
+ };
+
+ CodeReadinessHook hook =
+ CodeReadinessHook.builder("15").comparator(intComparator).build();
+
+ FlagEvaluationDetails validDetails = createDetailsWithMetadata("minCodeVersion", "10");
+ assertThatCode(() -> hook.after(hookContext, validDetails, Collections.emptyMap()))
+ .doesNotThrowAnyException();
+
+ FlagEvaluationDetails invalidDetails = createDetailsWithMetadata("minCodeVersion", "20");
+ assertThatThrownBy(() -> hook.after(hookContext, invalidDetails, Collections.emptyMap()))
+ .isInstanceOf(GeneralError.class)
+ .hasMessage("current version: \"15\" required minimum version: \"20\" check failed");
+ }
+
+ private FlagEvaluationDetails createDetailsWithMetadata(String key, Object value) {
+ ImmutableMetadata.ImmutableMetadataBuilder builder = ImmutableMetadata.builder();
+ if (value instanceof Boolean) {
+ builder.addBoolean(key, (Boolean) value);
+ } else {
+ builder.addString(key, value.toString());
+ }
+ return FlagEvaluationDetails.builder().flagMetadata(builder.build()).build();
+ }
+}
diff --git a/hooks/codereadiness/src/test/java/dev/openfeature/contrib/hooks/codereadiness/SemVerComparatorTest.java b/hooks/codereadiness/src/test/java/dev/openfeature/contrib/hooks/codereadiness/SemVerComparatorTest.java
new file mode 100644
index 000000000..001e7eb5b
--- /dev/null
+++ b/hooks/codereadiness/src/test/java/dev/openfeature/contrib/hooks/codereadiness/SemVerComparatorTest.java
@@ -0,0 +1,61 @@
+package dev.openfeature.contrib.hooks.codereadiness;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+class SemVerComparatorTest {
+
+ @ParameterizedTest
+ @CsvSource({
+ "1.2.0, 1.1.0, true",
+ "1.1.0, 1.1.0, true",
+ "v1.2.0, 1.1.0, true",
+ "1.2.0, v1.1.0, true",
+ "v1.2.0, v1.1.0, true",
+ "2.0.0, 1.9.9, true",
+ "1.0.0, 1.1.0, false",
+ "v1.0.0, v1.1.0, false"
+ })
+ @DisplayName("Should validate versions correctly according to SemVer rules")
+ void testVersionComparison(String currentVersion, String minCodeVersion, boolean expectedResult) throws Exception {
+ SemVerComparator comparator = new SemVerComparator();
+ boolean result = comparator.compare(comparator.parse(currentVersion), comparator.parse(minCodeVersion));
+ assertThat(result).isEqualTo(expectedResult);
+ }
+
+ @Test
+ @DisplayName("Should throw IllegalArgumentException when version is invalid semver in parse")
+ void testInvalidVersion() {
+ SemVerComparator comparator = new SemVerComparator();
+ assertThatThrownBy(() -> comparator.parse("invalid-version"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("invalid semver");
+ }
+
+ @Test
+ @DisplayName("Should throw NullPointerException when versionString is null in parse")
+ void testNullVersionString() {
+ SemVerComparator comparator = new SemVerComparator();
+ assertThatThrownBy(() -> comparator.parse(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("versionString cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should throw NullPointerException when arguments are null in compare")
+ void testNullArgumentsInCompare() throws Exception {
+ SemVerComparator comparator = new SemVerComparator();
+ org.semver4j.Semver valid = comparator.parse("1.0.0");
+ assertThatThrownBy(() -> comparator.compare(null, valid))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("currentVersion cannot be null");
+ assertThatThrownBy(() -> comparator.compare(valid, null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("minCodeVersion cannot be null");
+ }
+}
diff --git a/pom.xml b/pom.xml
index 1667517cd..29963bb06 100644
--- a/pom.xml
+++ b/pom.xml
@@ -32,6 +32,7 @@
tools/flagd-api
tools/flagd-core
hooks/open-telemetry
+ hooks/codereadiness
tools/junit-openfeature
providers/flagd
providers/flagsmith
diff --git a/release-please-config.json b/release-please-config.json
index 7cb16289d..d3281c2d5 100644
--- a/release-please-config.json
+++ b/release-please-config.json
@@ -161,6 +161,17 @@
"README.md"
]
},
+ "hooks/codereadiness": {
+ "package-name": "dev.openfeature.contrib.hooks.codereadiness",
+ "release-type": "simple",
+ "bump-minor-pre-major": true,
+ "bump-patch-for-minor-pre-major": true,
+ "versioning": "default",
+ "extra-files": [
+ "pom.xml",
+ "README.md"
+ ]
+ },
"tools/junit-openfeature": {
"package-name": "dev.openfeature.contrib.tools.junitopenfeature",
"release-type": "simple",