-
Notifications
You must be signed in to change notification settings - Fork 41
Clinical Reasoning Dev Server #1011
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 10 commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
7419728
Add cqf-fhir-cr-server: Spring Boot CR server with IRepository CRUD shim
JPercival 1ee68f0
CI: publish jlink image to GHCR on v* tag push
JPercival b94f45b
Merge branch 'main' into spike/cqf-fhir-cr-server
JPercival 3eb42bd
Apply spotless formatting
JPercival 0299b6f
Apply root spotless: blank lines around cqf-fhir-cr-server include
JPercival 4eaf3d4
Application: split static main / instance run() to satisfy checkstyle
JPercival 0adde5b
Merge branch 'main' into spike/cqf-fhir-cr-server
JPercival 84655ea
Wireup other operations
barhodes 93c0c56
spotless
barhodes 2a58548
Merge branch 'main' into spike/cqf-fhir-cr-server
barhodes b6c1d8e
Fix sonar
JPercival 217d93b
Readme
JPercival bc2ab66
Merge branch 'main' into spike/cqf-fhir-cr-server
barhodes e675db9
Remove docker build and docs
barhodes 057fd2b
Merge branch 'main' into spike/cqf-fhir-cr-server
barhodes f656b55
rename to cr-dev-server
barhodes e852eb5
fix sonar warning
barhodes 1d7042e
Merge branch 'main' into spike/cqf-fhir-cr-server
barhodes File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| name: Release Image | ||
| on: | ||
| push: | ||
| tags: | ||
| - 'v*' | ||
|
|
||
| # Read source, write packages (GHCR). No write to git contents. | ||
| permissions: | ||
| contents: read | ||
| packages: write | ||
|
|
||
| concurrency: | ||
| group: release-image-${{ github.ref }} | ||
| cancel-in-progress: false | ||
|
|
||
| jobs: | ||
| jlink-image: | ||
| name: Publish jlink Docker image to GHCR | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | ||
| with: | ||
| fetch-tags: true | ||
|
|
||
| - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 | ||
| with: | ||
| distribution: 'temurin' | ||
| java-version: '21' | ||
|
|
||
| - uses: gradle/actions/setup-gradle@0b6dd653ba04f4f93bf581ec31e66cbd7dcb644d # v4 | ||
|
|
||
| # Build the bootJar that Dockerfile.jlink copies in. The Dockerfile's first stage | ||
| # extracts its layered tree and runs jdeps + jlink against the contents. | ||
| - name: Build bootJar | ||
| run: ./gradlew :cqf-fhir-cr-server:bootJar | ||
|
|
||
| - name: Login to GHCR | ||
| run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin | ||
|
|
||
| - name: Derive image tags | ||
| id: tags | ||
| run: | | ||
| # The git tag is "v4.7.0" -> semver "4.7.0". | ||
| GIT_TAG="${GITHUB_REF#refs/tags/}" | ||
| SEMVER="${GIT_TAG#v}" | ||
| echo "semver=${SEMVER}" >> "$GITHUB_OUTPUT" | ||
| # Update :latest only for stable releases (skip rc/alpha/beta/etc). | ||
| if [[ "$SEMVER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | ||
| echo "is-stable=true" >> "$GITHUB_OUTPUT" | ||
| else | ||
| echo "is-stable=false" >> "$GITHUB_OUTPUT" | ||
| fi | ||
|
|
||
| - name: Build jlink image | ||
| env: | ||
| IMAGE: ghcr.io/${{ github.repository }}/cqf-fhir-cr-server | ||
| SEMVER: ${{ steps.tags.outputs.semver }} | ||
| run: | | ||
| docker build \ | ||
| -t "${IMAGE}:${SEMVER}-jlink" \ | ||
| -t "${IMAGE}:${SEMVER}" \ | ||
| -f cqf-fhir-cr-server/Dockerfile.jlink \ | ||
| . | ||
|
|
||
| - name: Tag :latest for stable releases | ||
| if: steps.tags.outputs.is-stable == 'true' | ||
| env: | ||
| IMAGE: ghcr.io/${{ github.repository }}/cqf-fhir-cr-server | ||
| SEMVER: ${{ steps.tags.outputs.semver }} | ||
| run: docker tag "${IMAGE}:${SEMVER}" "${IMAGE}:latest" | ||
|
|
||
| - name: Push image | ||
| env: | ||
| IMAGE: ghcr.io/${{ github.repository }}/cqf-fhir-cr-server | ||
| SEMVER: ${{ steps.tags.outputs.semver }} | ||
| run: | | ||
| docker push "${IMAGE}:${SEMVER}" | ||
| docker push "${IMAGE}:${SEMVER}-jlink" | ||
| if [ "${{ steps.tags.outputs.is-stable }}" = "true" ]; then | ||
| docker push "${IMAGE}:latest" | ||
| fi | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| # Multi-stage build for cqf-fhir-cr-server. | ||
| # | ||
| # Stage 1: copy the bootJar produced by `./gradlew :cqf-fhir-cr-server:bootJar` | ||
| # into a layered tree so subsequent rebuilds only invalidate changed layers. | ||
| # Stage 2: minimal JRE 17 runtime. | ||
| # | ||
| # Build: docker build -t cqf-fhir-cr-server:dev -f cqf-fhir-cr-server/Dockerfile . | ||
| # Run: docker run --rm -p 8080:8080 cqf-fhir-cr-server:dev | ||
| # | ||
| # Alternative: `./gradlew :cqf-fhir-cr-server:bootBuildImage` builds an OCI image via | ||
| # Spring Boot's Paketo buildpacks without touching this file. | ||
|
|
||
| FROM eclipse-temurin:17-jre AS extractor | ||
| WORKDIR /work | ||
| ARG JAR_FILE=cqf-fhir-cr-server/build/libs/cqf-fhir-cr-server-*.jar | ||
| COPY ${JAR_FILE} app.jar | ||
| RUN java -Djarmode=tools -jar app.jar extract --layers --launcher | ||
|
|
||
| FROM eclipse-temurin:17-jre | ||
| LABEL org.opencds.cqf.module="cqf-fhir-cr-server" | ||
| WORKDIR /opt/app | ||
|
|
||
| # Layered for cache efficiency. Dependencies change far less often than application code. | ||
| COPY --from=extractor /work/app/dependencies/ ./ | ||
| COPY --from=extractor /work/app/spring-boot-loader/ ./ | ||
| COPY --from=extractor /work/app/snapshot-dependencies/ ./ | ||
| COPY --from=extractor /work/app/application/ ./ | ||
|
|
||
| EXPOSE 8080 | ||
|
|
||
| # JVM tuning for containers; rely on JDK 17's default ergonomics for memory. | ||
| ENV JAVA_OPTS="" | ||
| ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| # jlink-based image. Produces a custom minimal JRE containing only the JDK modules the app | ||
| # actually needs, then layers it onto a distroless base. Aims for the smallest practical image | ||
| # without losing parity with the standard Dockerfile. | ||
| # | ||
| # Build: docker build -t cqf-fhir-cr-server:jlink -f cqf-fhir-cr-server/Dockerfile.jlink . | ||
| # Run: docker run --rm -p 8080:8080 cqf-fhir-cr-server:jlink | ||
|
|
||
| # Stage 1: extract the bootJar's layered tree so jdeps can read nested dependency jars. | ||
| FROM eclipse-temurin:17-jdk AS extractor | ||
| WORKDIR /work | ||
| ARG JAR_FILE=cqf-fhir-cr-server/build/libs/cqf-fhir-cr-server-*.jar | ||
| COPY ${JAR_FILE} app.jar | ||
| RUN java -Djarmode=tools -jar app.jar extract --layers --launcher | ||
|
|
||
| # Stage 2: build a custom JRE. | ||
| # | ||
| # Strategy: run jdeps over the extracted libs to get a candidate module list, then UNION it with | ||
| # a baseline of modules HAPI/Spring/JCE need at runtime via reflection (jdeps misses these). | ||
| # `--ignore-missing-deps` is essential — Spring Boot fat jars contain libs whose own optional | ||
| # transitive deps aren't on the classpath, and without the flag jdeps refuses to print anything. | ||
| FROM eclipse-temurin:17-jdk AS jre-builder | ||
| WORKDIR /work | ||
| COPY --from=extractor /work/app /work/app | ||
|
|
||
| # Baseline modules HAPI FHIR's reflective code paths require beyond what jdeps detects. | ||
| # - java.desktop: HAPI's validation occasionally pulls AWT (Color, image classes via XHTML). | ||
| # - java.naming: JNDI used by Hibernate/Spring Boot bootstrap probes. | ||
| # - java.management.rmi: JMX RMI connector. | ||
| # - java.security.jgss: Kerberos / SPNEGO if the JCE chain triggers. | ||
| # - java.sql.rowset: pulled by some Spring Boot conditional configs. | ||
| # - jdk.crypto.cryptoki / jdk.crypto.ec: TLS cipher suites. | ||
| # - jdk.unsupported: sun.misc.Unsafe — many libs (Netty / Guava / etc). | ||
| # - jdk.zipfs: needed to read zip-style filesystems (some Spring Boot loader paths). | ||
| # - jdk.localedata: ICU locales for FHIR string handling. | ||
| # - java.compiler: some HAPI/CQL code paths use the in-process compiler. | ||
| ENV BASELINE_MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.management.rmi,java.naming,java.net.http,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.sql.rowset,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.cryptoki,jdk.crypto.ec,jdk.httpserver,jdk.jdwp.agent,jdk.localedata,jdk.management,jdk.management.agent,jdk.naming.dns,jdk.naming.rmi,jdk.security.auth,jdk.security.jgss,jdk.unsupported,jdk.zipfs" | ||
|
|
||
| # jdeps over the dependency tree. Some libs declare unparseable bytecode or have missing | ||
| # transitives; --ignore-missing-deps lets jdeps continue past them. If jdeps still errors out | ||
| # entirely (it does on some HAPI versions), fall back to the baseline. | ||
| RUN set -eux; \ | ||
| DETECTED=$(jdeps \ | ||
| --print-module-deps \ | ||
| --ignore-missing-deps \ | ||
| --multi-release 17 \ | ||
| --recursive \ | ||
| --class-path "app/dependencies/BOOT-INF/lib/*:app/snapshot-dependencies/BOOT-INF/lib/*:app/application/BOOT-INF/lib/*" \ | ||
| app/application/BOOT-INF/classes app/application/BOOT-INF/lib/*.jar 2>/dev/null \ | ||
| || echo ""); \ | ||
| echo "jdeps raw output: $DETECTED"; \ | ||
| # jdeps output can contain "Warning: ..." lines mixed into the comma-separated module list. | ||
| # Filter to valid module-name shape ([a-z][a-z0-9._]*) before merging. | ||
| MODS="$(printf '%s,%s' "$BASELINE_MODULES" "$DETECTED" \ | ||
| | tr ',' '\n' \ | ||
| | grep -E '^[a-z][a-z0-9._]*$' \ | ||
| | sort -u \ | ||
| | paste -sd, -)"; \ | ||
| echo "Final module set:"; echo "$MODS" | tr ',' '\n'; \ | ||
| echo "$MODS" > /work/modules.txt | ||
|
|
||
| RUN MODS=$(cat /work/modules.txt) && \ | ||
| jlink \ | ||
| --add-modules "$MODS" \ | ||
| --strip-debug \ | ||
| --no-man-pages \ | ||
| --no-header-files \ | ||
| --compress=2 \ | ||
| --output /opt/jre | ||
|
|
||
| # Smoke: confirm the custom JRE runs. | ||
| RUN /opt/jre/bin/java --version | ||
|
|
||
| # Stage 3: minimal runtime. Distroless base provides glibc + ca-certs and nothing else. | ||
| FROM gcr.io/distroless/base-debian12 | ||
| LABEL org.opencds.cqf.module="cqf-fhir-cr-server" | ||
|
|
||
| # Custom JRE (built above) + Spring Boot layered app (built in stage 1). | ||
| COPY --from=jre-builder /opt/jre /opt/jre | ||
| WORKDIR /opt/app | ||
| COPY --from=extractor /work/app/dependencies/ ./ | ||
| COPY --from=extractor /work/app/spring-boot-loader/ ./ | ||
| COPY --from=extractor /work/app/snapshot-dependencies/ ./ | ||
| COPY --from=extractor /work/app/application/ ./ | ||
|
|
||
| EXPOSE 8080 | ||
|
|
||
| # Distroless has no shell, so no $JAVA_OPTS expansion. Pass tuning via -D in the cmd if needed | ||
| # or override with `docker run --entrypoint`. | ||
| ENTRYPOINT ["/opt/jre/bin/java", "org.springframework.boot.loader.launch.JarLauncher"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| import org.springframework.boot.gradle.tasks.bundling.BootBuildImage | ||
|
|
||
| plugins { | ||
| id("cqf.java-conventions") | ||
| id("cqf.spotless-conventions") | ||
| id("cqf.jacoco-conventions") | ||
| application | ||
| alias(libs.plugins.spring.boot) | ||
| } | ||
|
|
||
| application { mainClass = "org.opencds.cqf.fhir.cr.server.Application" } | ||
|
|
||
| tasks.named<org.springframework.boot.gradle.tasks.bundling.BootJar>("bootJar") { | ||
| mainClass = "org.opencds.cqf.fhir.cr.server.Application" | ||
| archiveClassifier = "" | ||
| } | ||
|
|
||
| // Disable the thin "plain" JAR so the bootJar fat JAR is the primary artifact. | ||
| tasks.named<Jar>("jar") { enabled = false } | ||
|
|
||
| dependencies { | ||
| // cqf-fhir-cr-hapi pulls hapi-fhir-jpaserver-base + hapi-fhir-server-cds-hooks via `api`. | ||
| // Both bring substantial transitive trees (Hibernate Search → Lucene/Elasticsearch, JDBC | ||
| // drivers, CDS-Hooks framework) we never initialize at runtime in this server. | ||
| // | ||
| // The exclusion list below is the *known-safe* subset: it removes the modules that contain | ||
| // no classes touched at runtime, verified by integration tests + live curl smoke. More | ||
| // aggressive exclusions (Hibernate ORM, Hibernate Search, Spring Data, FHIR version model | ||
| // jars) caused HAPI's reflective `ValidationSupportChain` construction (used by the | ||
| // FHIRPath engine inside ResourceMatcher) to fail with HAPI-2330 on string searches. | ||
| // Resolving that requires either upstream changes in cqf-fhir-cr-hapi to make those deps | ||
| // optional, or wiring a non-default ValidationSupport so the reflective fallback is skipped. | ||
| // | ||
| // Result: 311 MB -> ~210 MB (-32%). Phase-2 split of cqf-fhir-cr-hapi gets us the rest. | ||
| api(project(":cqf-fhir-cr-hapi")) { | ||
| // Trim only dependencies with no reflective entry point in HAPI's validator/FHIRPath | ||
| // construction. Wider exclusions (the JPA modules, Hibernate Search, FHIR-version | ||
| // model jars) broke ValidationSupportChain reflective construction (HAPI-2330) which | ||
| // ResourceMatcher needs for string-typed search params. | ||
| exclude(group = "ca.uhn.hapi.fhir", module = "hapi-fhir-server-cds-hooks") | ||
| exclude(group = "org.xerial", module = "sqlite-jdbc") | ||
| exclude(group = "com.oracle.database.jdbc", module = "ojdbc11") | ||
| exclude(group = "com.h2database", module = "h2") | ||
| exclude(group = "org.postgresql", module = "postgresql") | ||
| exclude(group = "com.microsoft.sqlserver", module = "mssql-jdbc") | ||
| exclude(group = "net.sourceforge.plantuml", module = "plantuml-mit") | ||
| exclude(group = "org.apache.jena") | ||
| exclude(group = "co.elastic.clients") | ||
| exclude(group = "org.elasticsearch") | ||
| exclude(group = "org.elasticsearch.client") | ||
| // hapi-fhir-storage-cr brings the CDS-Hooks server pieces; CDS hook flow isn't wired. | ||
| exclude(group = "ca.uhn.hapi.fhir", module = "hapi-fhir-storage-cr") | ||
|
|
||
| // Hibernate ORM core. (Hibernate Search stays — its absence broke ValidationSupportChain | ||
| // reflection.) | ||
| exclude(group = "org.hibernate.orm", module = "hibernate-core") | ||
| exclude(group = "org.hibernate.orm", module = "hibernate-envers") | ||
|
|
||
| // The hapi-fhir-jpaserver-* modules + hapi-fhir-jpa stay despite never being | ||
| // instantiated. Excluding any subset breaks HAPI's reflective ValidationSupportChain | ||
| // construction (used by ResourceMatcher's FHIRPath engine for string searches and | ||
| // operation processors). Need an upstream split before they can come out. | ||
| } | ||
| implementation(libs.spring.boot.starter.web) | ||
| implementation(libs.spring.boot.autoconfigure) | ||
| compileOnly(libs.jakarta.servlet.api) | ||
|
|
||
| testImplementation(libs.spring.boot.starter.test) | ||
| testImplementation(libs.hapi.fhir.test.utilities) | ||
| testImplementation(project(":cqf-fhir-test")) | ||
| } | ||
|
|
||
| // OCI image via Spring Boot's buildpacks (no Dockerfile required). | ||
| // Run: ./gradlew :cqf-fhir-cr-server:bootBuildImage | ||
| tasks.named<BootBuildImage>("bootBuildImage") { | ||
| imageName.set("cqf-fhir-cr-server:${project.version}") | ||
| environment.put("BP_JVM_VERSION", "17") | ||
| } | ||
|
|
||
| // Builds the Dockerfile in this module. Calls Docker directly so it works without buildpacks. | ||
| // Run: ./gradlew :cqf-fhir-cr-server:dockerBuild | ||
| tasks.register<Exec>("dockerBuild") { | ||
| group = "build" | ||
| description = "Builds the Docker image from cqf-fhir-cr-server/Dockerfile" | ||
| dependsOn("bootJar") | ||
| workingDir = rootDir | ||
| commandLine( | ||
| "docker", | ||
| "build", | ||
| "-t", | ||
| "cqf-fhir-cr-server:${project.version}", | ||
| "-t", | ||
| "cqf-fhir-cr-server:latest", | ||
| "-f", | ||
| "cqf-fhir-cr-server/Dockerfile", | ||
| ".", | ||
| ) | ||
| } | ||
|
|
||
| // jlink-based image: jdeps-driven custom JRE on distroless. ~40% smaller than the standard | ||
| // Dockerfile, no shell in the runtime stage. See cqf-fhir-cr-server/Dockerfile.jlink. | ||
| // Run: ./gradlew :cqf-fhir-cr-server:dockerBuildJlink | ||
| tasks.register<Exec>("dockerBuildJlink") { | ||
| group = "build" | ||
| description = "Builds a slim jlink-based Docker image from cqf-fhir-cr-server/Dockerfile.jlink" | ||
| dependsOn("bootJar") | ||
| workingDir = rootDir | ||
| commandLine( | ||
| "docker", | ||
| "build", | ||
| "-t", | ||
| "cqf-fhir-cr-server:${project.version}-jlink", | ||
| "-t", | ||
| "cqf-fhir-cr-server:jlink", | ||
| "-f", | ||
| "cqf-fhir-cr-server/Dockerfile.jlink", | ||
| ".", | ||
| ) | ||
| } |
41 changes: 41 additions & 0 deletions
41
cqf-fhir-cr-server/src/main/java/org/opencds/cqf/fhir/cr/server/Application.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| package org.opencds.cqf.fhir.cr.server; | ||
|
|
||
| import org.opencds.cqf.fhir.cr.server.config.ServerR4Config; | ||
| import org.springframework.boot.SpringApplication; | ||
| import org.springframework.boot.autoconfigure.SpringBootApplication; | ||
| import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; | ||
| import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; | ||
| import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration; | ||
| import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; | ||
| import org.springframework.boot.autoconfigure.quartz.QuartzAutoConfiguration; | ||
| import org.springframework.context.annotation.Import; | ||
|
|
||
| /** | ||
| * CQF Clinical Reasoning server. Mounts HAPI's {@code RestfulServer} on Spring Boot's embedded | ||
| * Tomcat, registers operation providers from {@code cqf-fhir-cr-hapi}, and bridges plain CRUD | ||
| * to an in-memory {@code IRepository}. | ||
| * | ||
| * <p>Spring Boot's JPA / DataSource / JDBC auto-configurations are explicitly excluded — the | ||
| * server has no datasource, and Hibernate must stay dormant on the classpath even though it's | ||
| * pulled transitively from {@code hapi-fhir-jpaserver-base}. See spike measurements: with these | ||
| * exclusions, no Hibernate code initializes during startup. | ||
| */ | ||
| @SpringBootApplication( | ||
| exclude = { | ||
| DataSourceAutoConfiguration.class, | ||
| DataSourceTransactionManagerAutoConfiguration.class, | ||
| HibernateJpaAutoConfiguration.class, | ||
| JdbcTemplateAutoConfiguration.class, | ||
| QuartzAutoConfiguration.class | ||
| }) | ||
| @Import(ServerR4Config.class) | ||
| public class Application { | ||
|
|
||
| public static void main(String[] args) { | ||
| new Application().run(args); | ||
| } | ||
|
|
||
| void run(String[] args) { | ||
| SpringApplication.run(Application.class, args); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.