diff --git a/.github/files/known_hosts b/.github/files/known_hosts new file mode 100644 index 0000000..e5fa657 --- /dev/null +++ b/.github/files/known_hosts @@ -0,0 +1,2 @@ +gitlab.com,172.65.251.78 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFSMqzJeV9rUzU4kWitGjeR4PWSa29SPqJ1fVkhtj3Hw9xjLVXVYrU9QlYWrOLXBpQ6KWjbjTDTdDkoohFzgbEY= +github.com,140.82.121.4 ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ== diff --git a/.github/scripts/create-native-image-build-config.sh b/.github/scripts/create-native-image-build-config.sh new file mode 100755 index 0000000..8c93d3b --- /dev/null +++ b/.github/scripts/create-native-image-build-config.sh @@ -0,0 +1,65 @@ +#!/bin/bash +set -euo pipefail +IFS=$'\n\t' + +say() { + what="$@" + echo "==> ${what}" +} + +cmd() { + config_output_dir="${1}" + args="${*:2}" + java -agentlib:native-image-agent=config-output-dir="${config_output_dir}" -jar build/libs/devex-*-all.jar gitlab clone ${args} +} + +test_clones_dir="test-clones" +native_image_config_dir="native-image-config" + +rm -rf "${native_image_config_dir}" +mkdir "${native_image_config_dir}" + +say "Asking for tool version" +cmd "${native_image_config_dir}/no-clone/version" -V + +say "Asking for tool help" +cmd "${native_image_config_dir}/no-clone/help" -h + +local_path="${test_clones_dir}/public-without-token-ssh-no-submodules-trace" +say "Asking to clone a public group without using a token, via ssh, without submodules" +GITLAB_TOKEN="" cmd "${native_image_config_dir}/${local_path}-1" --trace gitlab-clone-example "${local_path}" + +say "Asking to clone the same public group without using a token, via ssh, with submodules (effectively only initializing submodules)" +GITLAB_TOKEN="" cmd "${native_image_config_dir}/${local_path}-2" --trace gitlab-clone-example -r "${local_path}" + +local_path="${test_clones_dir}/public-with-token-ssh-with-submodules-debug" +say "Asking to clone a public group using a token, via ssh, with submodules" +cmd "${native_image_config_dir}/${local_path}" --debug -r gitlab-clone-example "${local_path}" + +local_path="${test_clones_dir}/public-with-token-https-with-submodules-verbose" +say "Asking to clone a public group using a token, via https, with submodules" +cmd "${native_image_config_dir}/${local_path}" -v -r -c HTTPS -u devex-bot gitlab-clone-example "${local_path}" + +local_path="${test_clones_dir}/private-without-token-ssh-with-submodules-verbose" +say "Asking to clone a private group without using a token, via ssh, with submodules" +GITLAB_TOKEN="" cmd "${native_image_config_dir}/${local_path}" -v -r gitlab-clone-example-private "${local_path}" || true + +local_path="${test_clones_dir}/public-without-token-https-with-submodules-verbose" +say "Asking to clone a private group without using a token, via https, with submodules" +GITLAB_TOKEN="" cmd "${native_image_config_dir}/${local_path}" -v -r -c HTTPS -u devex-bot gitlab-clone-example-private "${local_path}" || true + +local_path="${test_clones_dir}/private-with-token-ssh-with-submodules-very-verbose" +say "Asking to clone a private group using a token, via ssh, with submodules" +cmd "${native_image_config_dir}/${local_path}" -x -r gitlab-clone-example-private "${local_path}" + +local_path="${test_clones_dir}/public-with-token-https-with-submodules-very-verbose" +say "Asking to clone a private group using a token, via https, with submodules" +cmd "${native_image_config_dir}/${local_path}" -x -r -c HTTPS -u devex-bot gitlab-clone-example-private "${local_path}" + +local_path="${test_clones_dir}/public-by-id-with-token-very-verbose" +say "Asking to clone a public group by id using a token, via https, with submodules" +cmd "${native_image_config_dir}/${local_path}" -x -m id -r -c HTTPS -u devex-bot 11961707 "${local_path}" + +local_path="${test_clones_dir}/public-sub-group-by-full-path-with-token-very-verbose" +say "Asking to clone a public subgroup by full path using a token, via https, with submodules" +cmd "${native_image_config_dir}/${local_path}" -x -m full_path -r -c HTTPS -u devex-bot gitlab-clone-example/sub-group-2/sub-group-3 "${local_path}" diff --git a/.github/scripts/merge-native-image-build-config.sh b/.github/scripts/merge-native-image-build-config.sh new file mode 100755 index 0000000..de11e4f --- /dev/null +++ b/.github/scripts/merge-native-image-build-config.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -euo pipefail +IFS=$'\n\t' + +say() { + what="$@" + echo "==> ${what}" +} + +os="${1:-darwin}" + +native_image_config_dir="native-image-config" +output_dir="src/main/resources/META-INF/native-image" + +rm -rf "${output_dir}" +mkdir -p "${output_dir}" + +[[ -d "${native_image_config_dir}" ]] || exit 1 + + +say "Merging native-image build config" +input_dirs="" +for config_dir in "${native_image_config_dir}"/*/*; do + input_dirs+="--input-dir=${config_dir} " +done + +eval "graalvm/bin/native-image-configure-${os}" generate "${input_dirs}" --output-dir="${output_dir}" diff --git a/.github/scripts/run-native-binary.sh b/.github/scripts/run-native-binary.sh new file mode 100755 index 0000000..3c1c79c --- /dev/null +++ b/.github/scripts/run-native-binary.sh @@ -0,0 +1,56 @@ +#!/bin/bash +set -euo pipefail +IFS=$'\n\t' + +say() { + what="$@" + echo "==> ${what}" +} + +say "Asking for tool version" +build/native-image/application -V + +say "Asking for tool help" +build/native-image/application -h + +local_path="ssh-no-submodules" +say "Asking to clone a group, via ssh, without submodules" +build/native-image/application gitlab clone -x gitlab-clone-example "${local_path}" +[[ ! -f "${local_path}/gitlab-clone-example/a-project/some-project-sub-module/README.md" ]] + +say "Asking to clone the same group, via ssh, with submodules (effectively only initializing submodules)" +build/native-image/application gitlab clone -x -r gitlab-clone-example "${local_path}" +[[ -f "${local_path}/gitlab-clone-example/a-project/some-project-sub-module/README.md" ]] +cd "${local_path}/gitlab-clone-example/a-project" +[[ "$(git remote -v | head -n 1)" == *"git@"* ]] +cd - + +local_path="ssh-with-submodules" +say "Asking to clone group, via ssh, with submodules" +build/native-image/application gitlab clone -x -r gitlab-clone-example "${local_path}" +[[ -f "${local_path}/gitlab-clone-example/a-project/some-project-sub-module/README.md" ]] +cd "${local_path}/gitlab-clone-example/a-project" +[[ "$(git remote -v | head -n 1)" == *"git@"* ]] +cd - + +local_path="https-with-submodules" +say "Asking to clone group, via https, with submodules" +build/native-image/application gitlab clone -x -r -c HTTPS -u devex-bot gitlab-clone-example "${local_path}" +[[ -f "${local_path}/gitlab-clone-example/a-project/some-project-sub-module/README.md" ]] +cd "${local_path}/gitlab-clone-example/a-project" +[[ "$(git remote -v | head -n 1)" == *"https://"* ]] +cd - + +local_path="https-by-id" +say "Asking to clone group by id" +build/native-image/application gitlab clone -x -r -c HTTPS -u devex-bot -m id 11961707 "${local_path}" +[[ -f "${local_path}/gitlab-clone-example/a-project/some-project-sub-module/README.md" ]] +cd "${local_path}/gitlab-clone-example/a-project" +[[ "$(git remote -v | head -n 1)" == *"https://"* ]] +cd - + +local_path="ssh-by-full-path" +say "Asking to clone group by full path" +build/native-image/application gitlab clone -x -m full_path gitlab-clone-example/sub-group-2/sub-group-3 "${local_path}" +[[ -f "${local_path}/gitlab-clone-example/sub-group-2/sub-group-3/another-project/README.md" ]] +cd - diff --git a/.github/workflows/create-release.yaml b/.github/workflows/create-release.yaml new file mode 100644 index 0000000..8bca055 --- /dev/null +++ b/.github/workflows/create-release.yaml @@ -0,0 +1,118 @@ +name: "Continuous Delivery" + +on: + push: + tags: + - v* + +jobs: + build_release: + name: "Build release" + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ ubuntu-latest, macos-latest ] + steps: + - name: "[${{ runner.os }}] Checkout sources" + uses: actions/checkout@v2 + - name: "[${{ runner.os }}] Cache dependencies" + uses: actions/cache@v2 + with: + path: | + ~/.gradle + ${GITHUB_WORKSPACE}/.gradle + key: ${{ matrix.os }}-devex-release-java11 + - name: "[${{ runner.os }}] Install GraalVM" + uses: DeLaGuardo/setup-graalvm@4.0 + with: + graalvm: '21.1.0' + java: 'java11' + arch: 'amd64' + - name: "[${{ runner.os }}] Install native-image" + run: gu install native-image + - name: "[${{ runner.os }}] Setup SSH" + run: | + mkdir -p ~/.ssh + cat .github/files/known_hosts > ~/.ssh/known_hosts + echo "${bot_devex_ssh_private_key}" > ~/.ssh/id_rsa + chmod -R 500 ~/.ssh + env: + bot_devex_ssh_private_key: ${{ secrets.BOT_DEVEX_SSH_PRIVATE_KEY }} + - name: "[${{ runner.os }}] Gradle build" + run: ./gradlew build nativeImage + env: + GITLAB_TOKEN: ${{ secrets.BOT_GITLAB_TOKEN_READ_API }} + - name: "[${{ runner.os }}] Run and test native image" + run: .github/scripts/run-native-binary.sh + env: + GITLAB_TOKEN: ${{ secrets.BOT_GITLAB_TOKEN_READ_API }} + - name: "[${{ runner.os }}] Clean up" + if: ${{ always() }} + run: sudo rm -rf ~/.ssh + - name: '[${{ runner.os }}] Upload artifacts' + uses: actions/upload-artifact@v2 + with: + name: devex-${{ runner.os }} + path: build/native-image/application + if-no-files-found: error + + create_release: + name: "Create release" + needs: build_release + runs-on: ubuntu-latest + env: + release_body_file: "body.txt" + steps: + - name: "Checkout sources" + uses: actions/checkout@v2 + - name: "Download all workflow run artifacts" + uses: actions/download-artifact@v2 + with: + path: "artifacts" + - name: "Generate changelog" + id: changelog + uses: metcalfc/changelog-generator@v0.4.4 + with: + myToken: ${{ secrets.GITHUB_TOKEN }} + - name: "Prepare release files" + id: release-files + run: | + version=${tag_ref/refs\/tags\//} + + # Create sha256sum for each release file + mkdir release + cd artifacts + for file in */*; do release_name="$(dirname ${file})"; cp "${file}" "../release/${release_name}-${version}"; done + cd ../release + for file in *; do sha256sum "${file}" > "${file}.sha256sum"; done + tar czf all-files-${version}.tar.gz * + sha256sum all-files-${version}.tar.gz > all-files-${version}.tar.gz.sha256sum + cd .. + + # Create release text + cat <<'EOF' > "${release_body_file}" + ## SHA 256 + EOF + + cd release + for file in *.sha256sum; do echo "- $(cat "${file}")" >> "../${release_body_file}"; done + cd .. + + cat <<'EOF' >> "${release_body_file}" + + ## Change log + EOF + + echo "${change_log}" >> "${release_body_file}" + env: + tag_ref: ${{ github.ref }} + change_log: ${{ steps.changelog.outputs.changelog }} + - name: "Create release" + uses: softprops/action-gh-release@v1 + with: + body_path: ${{ env.release_body_file }} + files: release/* + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml new file mode 100644 index 0000000..2af8092 --- /dev/null +++ b/.github/workflows/development.yml @@ -0,0 +1,76 @@ +name: "Continuous Integration" + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + name: "Build code" + runs-on: ubuntu-latest + steps: + - name: "Checkout sources" + uses: actions/checkout@v2 + - name: "Cache dependencies" + uses: actions/cache@v2 + with: + path: | + ~/.gradle + ${GITHUB_WORKSPACE}/.gradle + ~/.cache/pip/ + key: ${{ runner.os }}-devex-development-java11 + - name: "Install GraalVM" + uses: DeLaGuardo/setup-graalvm@4.0 + with: + graalvm: '21.1.0' + java: 'java11' + arch: 'amd64' + - name: "Install native-image" + run: | + gu install native-image + - name: "Setup SSH" + run: | + mkdir -p ~/.ssh + cat .github/files/known_hosts > ~/.ssh/known_hosts + echo "${bot_devex_ssh_private_key}" > ~/.ssh/id_rsa + chmod -R 500 ~/.ssh + env: + bot_devex_ssh_private_key: ${{ secrets.BOT_DEVEX_SSH_PRIVATE_KEY }} + - name: "Gradle build" + run: ./gradlew build + env: + GITLAB_TOKEN: ${{ secrets.BOT_GITLAB_TOKEN_READ_API }} + - name: "Publish Unit Test Results" + uses: EnricoMi/publish-unit-test-result-action/composite@v1 + if: always() + with: + files: build/test-results/test/*.xml + - name: "Create native-image build config" + run: | + .github/scripts/create-native-image-build-config.sh + .github/scripts/merge-native-image-build-config.sh linux + env: + GITLAB_TOKEN: ${{ secrets.BOT_GITLAB_TOKEN_READ_API }} + - name: "Gradle nativeImage" + run: ./gradlew nativeImage + env: + GITLAB_TOKEN: ${{ secrets.BOT_GITLAB_TOKEN_READ_API }} + - name: "Run and test native binary" + run: .github/scripts/run-native-binary.sh + env: + GITLAB_TOKEN: ${{ secrets.BOT_GITLAB_TOKEN_READ_API }} + - uses: EndBug/add-and-commit@v7 # You can change this to use a specific version + with: + add: "src/main/resources/META-INF/native-image" + author_name: "DevEx Bot" + author_email: "devex.bot@gmail.com" + default_author: "user_info" + message: "Update native-image build config" + branch: ${{ github.head_ref }} + - name: "Clean up" + if: ${{ always() }} + run: sudo rm -rf ~/.ssh diff --git a/.github/workflows/git-tag.yaml b/.github/workflows/git-tag.yaml new file mode 100644 index 0000000..55210d0 --- /dev/null +++ b/.github/workflows/git-tag.yaml @@ -0,0 +1,72 @@ +name: "Tag git" + +on: + push: + branches: + - main + +jobs: + tag: + name: "Create git tag" + runs-on: ubuntu-latest + env: + tooling_dir: ${{ github.workspace }}/../tooling + ssh_dir: /home/runner/.ssh + if: "!contains(github.event.head_commit.message, 'Increment version')" + steps: + - name: "Cache tooling" + uses: actions/cache@v2 + with: + path: ${tooling_dir} + key: ${{ runner.os }}-devex-git-tag + - name: "Setup tools" + run: | + mkdir -p ${tooling_dir} + cd ${tooling_dir} + wget --no-clobber https://github.com/miguelaferreira/semver_bash/archive/master.zip + unzip master.zip + cd semver_bash-master + sudo mv semver.sh bump.sh /bin + sudo chmod +x /bin/bump.sh + - name: "Checkout sources" + uses: actions/checkout@v2 + - name: "Bump version number" + id: bump + run: | + version="$(cat ${version_file})" + bump.sh "${version}" > ${version_file} + echo "::set-output name=version-file::${version_file}" + echo "::set-output name=version::${version}" + echo "::set-output name=new-version::$(cat ${version_file})" + env: + version_file: VERSION + - name: "Configure git" + run: | + git remote rm origin + git remote add origin git@github.com:miguelaferreira/devex-cli.git + git config --global user.email "devex.bot@gmail.com" + git config --global user.name "DevEx Bot" + mkdir ${ssh_dir} + echo "${ssh_config}" > ${ssh_dir}/config + echo "${bot_devex_ssh_private_key}" > ${ssh_dir}/id_rsa + chmod -R 500 ${ssh_dir} + env: + ssh_config: ${{ secrets.SSH_CONFIG }} + bot_devex_ssh_private_key: ${{ secrets.BOT_DEVEX_SSH_PRIVATE_KEY }} + - name: "Create tag" + run: | + git tag "${tag}" + git push --tags + env: + tag: "v${{ steps.bump.outputs.version }}" + - name: "Create bump commit" + run: | + git push --set-upstream origin main + git add ${version_file} + git commit -m "${commit_message}" + git push + env: + version_file: ${{ steps.bump.outputs.version-file }} + commit_message: "Increment version from '${{ steps.bump.outputs.version }}' to '${{ steps.bump.outputs.new-version }}'" + - name: "Clean up" + run: sudo rm -rf ${ssh_dir} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c6fc227 --- /dev/null +++ b/.gitignore @@ -0,0 +1,69 @@ +Thumbs.db +.DS_Store +.gradle +build/ +target/ +out/ +.idea +*.iml +*.ipr +*.iws +.project +.settings +.classpath +.factorypath + +# Created by https://www.toptal.com/developers/gitignore/api/java +# Edit at https://www.toptal.com/developers/gitignore?templates=java + +### Java ### +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +# End of https://www.toptal.com/developers/gitignore/api/java + + +# Created by https://www.toptal.com/developers/gitignore/api/gradle +# Edit at https://www.toptal.com/developers/gitignore?templates=gradle + +### Gradle ### +.gradle +build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Cache of project +.gradletasknamecache + +# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 +# gradle/wrapper/gradle-wrapper.properties + +### Gradle Patch ### +**/build/ + +# End of https://www.toptal.com/developers/gitignore/api/gradle diff --git a/README.md b/README.md index 262d52e..1e52add 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,241 @@ +[![Continuous Integration](https://github.com/miguelaferreira/devex/actions/workflows/development.yml/badge.svg)](https://github.com/miguelaferreira/devex/actions/workflows/development.yml) +[![Continuous Delivery](https://github.com/miguelaferreira/devex/actions/workflows/create-release.yaml/badge.svg)](https://github.com/miguelaferreira/devex/actions/workflows/create-release.yaml) +[![Known Vulnerabilities](https://snyk.io/test/github/miguelaferreira/devex/badge.svg)](https://snyk.io/test/github/miguelaferreira/devex) + # devex-cli -Development Experience command line tool + +Development Experience command line tools. + +## Installing + +The `devex` tool is built for two operating systems Linux and macOS. Each release on this repository provides +binaries for these two operating systems. To install the tool, either download the binary from the latest release, make +it executable and place it on a reachable path; or use `brew`. + +```bash +brew install miguelaferreira/tools/devex-cli +``` + +## GitLab + +GitLab offers the ability to create groups of repositories and then leverage those groups to manage multiple +repositories at one. Things like CI/CD, user membership can be defined at the group level and then inherited by all the +underlying repositories. Furthermore, it's also possible to create relationships between repositories simply by +leveraging the group structure. For example, one can include git sub-modules and reference them by their relative path. + +It's handy, and sometimes needed, to clone the groups of repositories preserving the group structure. That is what this +tool does. + +## Usage + +Both the gitlab url (`GITLAB_URL`) and the private token (`GITLAB_TOKEN`) for accessing GitLab API are read from the +environment. The GitLab url defaults to [https://gitlab.com](https://gitlab.com) when not defined in the environment. +For cloning public groups no token is needed, for private groups a token with scope `read_api` is required. See +GitLab's [Limiting scopes of a personal access token](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#limiting-scopes-of-a-personal-access-token) +for more details on token scopes. + +### Public groups + +Cloning public groups does not require a gitlab token, however without a token it won't be possible to detect the version of GitLab server. +That always results in an error message, and in defaulting to using the `group_descendants` API, which is not available in versions of GitLab pre `13.5`. + +**Cloning a public group by name, to the current directory, using SSH protocol** +```bash +devex gitlab clone open-source-devex +``` +**Cloning a public group by name, to the current directory, initializing git submodules, using SSH protocol** +```bash +devex gitlab clone --recurse-submodules open-source-devex +``` +**Cloning a group by name, to a specified location, using SSH protocol** +```bash +devex gitlab clone open-source-devex ~/some-path-on-my-home +``` +**Cloning a public group by id, to the current directory, using SSH protocol** +```bash +devex gitlab clone --search-mode id 2150497 +``` +**Cloning a public group by path, to the current directory, using SSH protocol** +```bash +devex gitlab clone --search-mode full_path open-source-devex/terraform-modules/aws +``` +**Cloning a public group by name, to the current directory, using HTTPS protocol** +```bash +devex gitlab clone --clone-protocol https open-source-devex +``` + +### Private groups and self-hosted GitLab servers + +Cloning private groups requires a gitlab token. +Cloning from GitLab servers other than [gitlab.com](https://gitlab.com) requires the url for the server. + +**Cloning a private group by name, to the current directory, using SSH protocol** +```bash +GITLAB_TOKEN="..." devex gitlab clone "some private group" +``` +**Cloning a private group by name, to the current directory, using HTTPS protocol** +```bash +GITLAB_TOKEN="..." devex gitlab clone --clone-protocol https --https-username miguelaferreira "some private group" +``` +**Cloning a private group by name, to the current directory, using SSH protocol, from a self-hosted GitLab server** +```bash +GITLAB_URL="https://gitlab.acme.com" GITLAB_TOKEN="..." devex gitlab clone "some private group" +``` + +### Full usage description +``` +$ devex gitlab clone -h +Usage: + +Clone an entire GitLab group with all sub-groups and repositories. +While cloning initialize project git sub-modules (may require two runs due to ordering of projects). +When a project is already cloned, tries to initialize git sub-modules. + +devex gitlab clone [-hrvVx] [--debug] [--trace] [-u[=]] [-c=] [-m=] GROUP PATH + +GitLab configuration: + +The GitLab URL and private token are read from the environment, using GITLAB_URL and GITLAB_TOKEN variables. +GITLAB_URL defaults to 'https://gitlab.com'. +The GitLab token is used for both querying the GitLab API and discover the group to clone and as the password for +cloning using HTTPS. +No token is needed for public groups and repositories. + +Parameters: + GROUP The GitLab group to clone. + PATH The local path where to create the group clone. + +Options: + -h, --help Show this help message and exit. + -V, --version Print version information and exit. + -r, --recurse-submodules Initialize project submodules. If projects are already cloned try and initialize + sub-modules anyway. + -c, --clone-protocol= + Chose the transport protocol used clone the project repositories. Valid values: SSH, HTTPS. + -m, --search-mode= + Chose how the group is searched for. Groups can be searched by name or full path. Valid + values: NAME, FULL_PATH, ID. + -u, --https-username[=] + The username to authenticate with when the HTTPS clone protocol is selected. This option + is required when cloning private groups, in which case the GitLab token will be used as + the password. + -v, --verbose Print out extra information about what the tool is doing. + -x, --very-verbose Print out even more information about what the tool is doing. + --debug Sets all loggers to DEBUG level. + --trace Sets all loggers to TRACE level. WARNING: this setting will leak the GitLab token or + password to the logs, use with caution. + +Copyright(c) 2021 - Miguel Ferreira - GitHub/GitLab: @miguelaferreira +``` + +### Protocols + +The tool supports both SSH and HTTPS protocols for cloning projects, SSH being the default protocol. + +#### SSH + +There are three requirements for cloning via SSH that apply to both public and private groups: + +1. A known hosts file containing and entry for the GitLab server must exist in the default + location (`${HOME}/.ssh/known_hosts`). This entry looks something like + this: `gitlab.com,172.65.251.78 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFSMqzJeV9rUzU4kWitGjeR4PWSa29SPqJ1fVkhtj3Hw9xjLVXVYrU9QlYWrOLXBpQ6KWjbjTDTdDkoohFzgbEY=` +2. A private key must exist in the default location (`${HOME}/.ssh/id_rsa`) or in a different location as long there is + an entry for the GitLab server in the ssh client configuration that points to the correct key. + See [GitLab documentation on configuring SSH access](https://docs.gitlab.com/ee/ssh/) for more information on how to + set this up +3. The private key must be in the PEM format, the keyfile needs to start with the line: + `-----BEGIN RSA PRIVATE KEY-----` + _Note: Support for the OpenSSH key format will be available once a native binary can be produced with a Java15 + GraalVM native-image. See Issue #16 to track progress of this_ + +Cloning via HTTPS (cli option `--clone-protocol=HTTPS`) does not require any setup for public groups. For private +groups, the username needs to be provided (cli option `--https-username=`). The GitLab token specified on the +environment will be used as the password for the HTTPS authentication. + +## Development + +### Setup + +SDKMAN automates the process of installing and upgrading sdks, namely Java sdks. Install is via. + +```bash +curl -s "https://get.sdkman.io" | bash +``` + +Then install GraalVM. + +```bash +sdk install java 21.1.0.r11-grl +``` + +Load the installed GraalVM on the current terminal. + +```bash +sdk use java 21.1.0.r11-grl +``` + +To build native images using GraalVM it is necessary to install the `native-image` tool. + +``` +~/.sdkman/candidates/java/21.1.0.r11-grl/bin/gu install native-image +``` + +You should be ready to build the tool. + +### Build with Gradle + +Gradle is configured to build both executable jars and GraalVM native images. Gradle will also want to run tests, and +the tests require a GitLab token with `read_api` scope. The token is picked up from the environment +variable `GITLAB_TOKEN`. The tests can be skipped with the gradle flag `-x test`, in which case the GitLab token isn't +needed anymore. + +To build an executable jar run gradle task `build`. + +```bash +GITLAB_TOKEN="..." ./gradlew clean build +``` + +The executable jar is created under `build/libs/`, and it will be called something like `devex-VERSION-all.jar`. +To execute that jar run `java`. + +```bash +java -jar build/libs/devex-*-all.jar -h +``` + +To build a GraalVM native binary run the `nativeImage` gradle task. + +```bash +GITLAB_TOKEN="..." ./gradlew clean nativeImage +``` + +The binary will be created under `build/native-image/application`. To execute the native binary run it. + +```bash +build/native-image/application -h +``` + +### GraalVM Config + +In order to properly build a native binary some configuration needs to be generated from running the app as a jar. That +configuration is then included as a resource for the application, and the native image builder will load that to +properly create the native binary. That can be done by running the app from jar while setting a JVM agent to collect the +configuration. During the app run all functionality should be exercised. + +``` +./gradlew clean build +java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image -jar build/libs/devex-*-all.jar ... +``` + +However, not all functionality of the app can be exercised in a single run (eg. cloning via SSH vs HTTPS). Therefore, +different executions need to be made (as many as different and independent features of the app), each generating a set +of config, which at the end needs to be merged. See +the [native-image manual](https://www.graalvm.org/reference-manual/native-image/BuildConfiguration/#the-native-image-configure-tool) +for more information on how this works. Since the tool that merges the configuration (`native-image-configure-launcher`) +is not shipped with graalvm releases, it has to be built. This project includes two binaries of the tool, one for macOS +and the other for Linux, under [graalvm/bin](https://github.com/miguelaferreira/devex-cli/blob/master/graalvm/bin). During CI workflows that run on PR to `main` branch, the +app is executed with the `native-image-agent` producing different sets of configurations for different combinations of +input options and parameters. This is done in +script [.github/scripts/create-native-image-build-config.sh](https://github.com/miguelaferreira/devex-cli/blob/master/.github/scripts/create-native-image-build-config.sh). Then +the generated configurations are merged into the project's sources by another +script, [.github/scripts/merge-native-image-build-config.sh](https://github.com/miguelaferreira/devex-cli/blob/master/.github/scripts/merge-native-image-build-config.sh). +Finally, a new commit is made to the PR branch with the updated configuration. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..5a4491b --- /dev/null +++ b/build.gradle @@ -0,0 +1,80 @@ +plugins { + id("com.github.johnrengelman.shadow") version "7.0.0" + id("io.micronaut.application") version "1.4.2" + id 'org.javamodularity.moduleplugin' version '1.8.3' +} + +version = rootProject.file('VERSION').text.trim() +group = "devex" + +repositories { + mavenCentral() +} + +micronaut { + testRuntime("junit5") + processing { + incremental(true) + annotations("devex.*") + } +} + +dependencies { + compileOnly 'org.projectlombok:lombok:1.18.20' + annotationProcessor 'org.projectlombok:lombok:1.18.20' + + annotationProcessor("info.picocli:picocli-codegen") + implementation("info.picocli:picocli") + implementation("io.micronaut:micronaut-http-client") + implementation("io.micronaut:micronaut-runtime") + implementation("io.micronaut.picocli:micronaut-picocli") + implementation("io.micronaut:micronaut-validation") + + + implementation 'org.slf4j:slf4j-api:1.7.30' + implementation 'ch.qos.logback:logback-classic:1.2.3' + + + implementation 'io.vavr:vavr:0.10.3' + + implementation 'org.eclipse.jgit:org.eclipse.jgit:5.11.0.202103091610-r' + implementation('org.eclipse.jgit:org.eclipse.jgit.ssh.jsch:5.11.0.202103091610-r') { + exclude module: 'jsch' + } + + implementation 'com.github.mwiede:jsch:0.1.62' + + testCompileOnly 'org.projectlombok:lombok:1.18.20' + testAnnotationProcessor 'org.projectlombok:lombok:1.18.20' + testImplementation 'org.assertj:assertj-core:3.11.1' + testImplementation 'org.assertj:assertj-vavr:0.4.1' + + testImplementation("org.junit.jupiter:junit-jupiter-engine") // make gradle compileTestJava work +} + +application { + mainClass.set("devex.DevexCommand") +} + +java { + modularity.inferModulePath.set(true) + sourceCompatibility = JavaVersion.toVersion("11") + targetCompatibility = JavaVersion.toVersion("11") +} + +test { + environment "GITLAB_TOKEN", System.getenv('GITLAB_TOKEN') + moduleOptions { + runOnClasspath = true + } +} + +processResources { + from("VERSION") +} + +modularity.patchModule('java.annotation', 'jsr305-3.0.2.jar') +modularity.patchModule('org.eclipse.jgit', 'org.eclipse.jgit.ssh.jsch-5.11.0.202103091610-r.jar') +modularity.patchModule('io.micronaut.runtime', "micronaut-context-${ext.micronautVersion}.jar") + +mainClassName = "$moduleName/devex.DevexCommand" diff --git a/graalvm/bin/native-image-configure-darwin b/graalvm/bin/native-image-configure-darwin new file mode 100755 index 0000000..82b89ae Binary files /dev/null and b/graalvm/bin/native-image-configure-darwin differ diff --git a/graalvm/bin/native-image-configure-linux b/graalvm/bin/native-image-configure-linux new file mode 100755 index 0000000..17d6dbc Binary files /dev/null and b/graalvm/bin/native-image-configure-linux differ diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..12879bf --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +micronautVersion=2.5.4 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..3c4101c --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.0-all.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# 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 +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100755 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/micronaut-cli.yml b/micronaut-cli.yml new file mode 100644 index 0000000..b0aba02 --- /dev/null +++ b/micronaut-cli.yml @@ -0,0 +1,6 @@ +applicationType: cli +defaultPackage: devex +testFramework: junit +sourceLanguage: java +buildTool: gradle +features: [annotation-api, app-name, gradle, http-client, java, junit, logback, picocli, picocli-java-application, picocli-junit, readme, shade, yaml] diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..c36bfcc --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ + +rootProject.name="devex" diff --git a/src/main/java/devex/DevexCommand.java b/src/main/java/devex/DevexCommand.java new file mode 100644 index 0000000..73fa1b4 --- /dev/null +++ b/src/main/java/devex/DevexCommand.java @@ -0,0 +1,150 @@ +package devex; + +import ch.qos.logback.core.joran.spi.JoranException; +import io.micronaut.configuration.picocli.MicronautFactory; +import io.micronaut.context.ApplicationContext; +import io.micronaut.context.env.Environment; +import io.micronaut.logging.LogLevel; +import io.micronaut.logging.LoggingSystem; +import lombok.extern.slf4j.Slf4j; +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +import javax.inject.Inject; +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.stream.Collectors; + +@Slf4j +@Command( + name = "devex", + headerHeading = "Usage:%n%n", + synopsisHeading = "%n", + header = { + "Developer experience tools, saving time by automating gruntwork." + }, + descriptionHeading = "%nDescription:%n%n", + description = { + "The devex cli it a collection of tools aimed at automating day to day tasks performed by developers.", + "The tools are grouped by the platform they work against, currently GitHub or GitLab.", + "For each platform there is a different subcommand." + }, + footer = { + "%nCopyright(c) 2021 - Miguel Ferreira - GitHub/GitLab: @miguelaferreira" + }, + parameterListHeading = "%nParameters:%n", + optionListHeading = "%nOptions:%n", + mixinStandardHelpOptions = true, + versionProvider = DevexCommand.AppVersionProvider.class, + sortOptions = false, + usageHelpAutoWidth = true, + subcommands = {GitlabCommand.class}, + scope = CommandLine.ScopeType.INHERIT +) +public class DevexCommand { + + @Option( + order = 10, + names = {"-v", "--verbose"}, + description = "Print out extra information about what the tool is doing.", + scope = CommandLine.ScopeType.INHERIT + ) + private boolean verbose; + + @Option( + order = 11, + names = {"-x", "--very-verbose"}, + description = "Print out even more information about what the tool is doing.", + scope = CommandLine.ScopeType.INHERIT + ) + private boolean veryVerbose; + + @Option( + order = 12, + names = {"--debug"}, + description = "Sets all loggers to DEBUG level.", + scope = CommandLine.ScopeType.INHERIT + ) + private boolean debug; + + @Option( + order = 13, + names = {"--trace"}, + description = "Sets all loggers to TRACE level. WARNING: this setting will leak tokens used for HTTP authentication (eg. the GitLab token) to the logs, use with caution.", + scope = CommandLine.ScopeType.INHERIT + ) + private boolean trace; + + @Inject + LoggingSystem loggingSystem; + + static int execute(String[] args) { + final ApplicationContext ctx = ApplicationContext.builder(GitlabCloneCommand.class, Environment.CLI).start(); + return execute(ctx, args); + } + + public static int execute(ApplicationContext ctx, String[] args) { + try (ctx) { + final DevexCommand app = ctx.getBean(DevexCommand.class); + return new CommandLine(app, new MicronautFactory(ctx)) + .setCaseInsensitiveEnumValuesAllowed(true) + .setAbbreviatedOptionsAllowed(true) + .setExecutionStrategy(app::executionStrategy) + .execute(args); + } + } + + public static void main(String[] args) { + int exitCode = execute(args); + System.exit(exitCode); + } + + private int executionStrategy(CommandLine.ParseResult parseResult) { + configureLogging(); + log.debug("devex {}", new AppVersionProvider().getVersionText()); + return new CommandLine.RunLast().execute(parseResult); // default execution strategy + } + + private void configureLogging() { + try { + if (trace) { + LoggingConfiguration.configureLoggers(loggingSystem, LogLevel.TRACE, true); + log.trace("Set all loggers to TRACE"); + } else if (debug) { + LoggingConfiguration.configureLoggers(loggingSystem, LogLevel.DEBUG, true); + log.debug("Set all loggers to DEBUG"); + } else if (veryVerbose) { + LoggingConfiguration.configureLoggers(loggingSystem, LogLevel.TRACE, false); + log.trace("Set application loggers to TRACE"); + } else if (verbose) { + LoggingConfiguration.configureLoggers(loggingSystem, LogLevel.DEBUG, false); + log.debug("Set application loggers to DEBUG"); + } else { + LoggingConfiguration.configureLoggers(loggingSystem, LogLevel.INFO, false); + } + } catch (JoranException e) { + System.err.println("ERROR: failed to configure loggers."); + } + } + + static class AppVersionProvider implements CommandLine.IVersionProvider { + + public String getVersionText() { + return String.join("", new AppVersionProvider().getVersion()); + } + + @Override + public String[] getVersion() { + final InputStream in = AppVersionProvider.class.getResourceAsStream("/VERSION"); + if (in != null) { + BufferedReader reader = new BufferedReader(new InputStreamReader(in)); + String version = reader.lines().collect(Collectors.joining()); + return new String[]{"v" + version}; + } else { + return new String[]{"No version"}; + } + } + } +} diff --git a/src/main/java/devex/GitlabCloneCommand.java b/src/main/java/devex/GitlabCloneCommand.java new file mode 100644 index 0000000..23d7f12 --- /dev/null +++ b/src/main/java/devex/GitlabCloneCommand.java @@ -0,0 +1,133 @@ +package devex; + + +import devex.git.GitCloneProtocol; +import devex.git.GitService; +import devex.gitlab.GitlabGroup; +import devex.gitlab.GitlabGroupSearchMode; +import devex.gitlab.GitlabProject; +import devex.gitlab.GitlabService; +import io.reactivex.Flowable; +import io.vavr.Tuple; +import io.vavr.Tuple2; +import io.vavr.control.Either; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.jgit.api.Git; +import picocli.CommandLine; +import picocli.CommandLine.Command; + +import javax.inject.Inject; + +@Slf4j +@Command( + name = "clone", + aliases = "cl", + header = { + "Clone an entire GitLab group with all sub-groups and repositories.", + "While cloning initialize project git sub-modules (may require two runs due to ordering of projects).", + "When a project is already cloned, tries to initialize git sub-modules." + } +) +public class GitlabCloneCommand implements Runnable { + + @CommandLine.Option( + order = 0, + names = {"-r", "--recurse-submodules"}, + description = "Initialize project submodules. If projects are already cloned try and initialize sub-modules anyway.", + defaultValue = "false" + ) + private boolean recurseSubmodules; + + @CommandLine.Option( + order = 1, + names = {"-c", "--clone-protocol"}, + description = "Chose the transport protocol used clone the project repositories. Valid values: ${COMPLETION-CANDIDATES}.", + defaultValue = "SSH" + ) + private GitCloneProtocol cloneProtocol; + + @CommandLine.Option( + order = 2, + names = {"-m", "--search-mode"}, + description = "Chose how the group is searched for. Groups can be searched by name or full path. Valid values: ${COMPLETION-CANDIDATES}.", + defaultValue = "NAME" + ) + private GitlabGroupSearchMode searchMode; + + @CommandLine.Option( + order = 3, + names = {"-u", "--https-username"}, + description = "The username to authenticate with when the HTTPS clone protocol is selected. This option is required when cloning private groups, in which case the GitLab token will be used as the password.", + arity = "0..1", + interactive = true + ) + private String httpsUsername; + + @CommandLine.Parameters( + index = "0", + paramLabel = "GROUP", + description = "The GitLab group to clone." + ) + private String gitlabGroup; + + @CommandLine.Parameters( + index = "1", + paramLabel = "PATH", + description = "The local path where to create the group clone.", + defaultValue = ".", + showDefaultValue = CommandLine.Help.Visibility.ON_DEMAND + ) + private String localPath; + + @Inject + GitlabService gitlabService; + @Inject + GitService gitService; + + @Override + public void run() { + configureGitService(); + cloneGroup(); + } + + private void configureGitService() { + gitService.setCloneProtocol(cloneProtocol); + gitService.setHttpsUsername(httpsUsername); + } + + private void cloneGroup() { + log.info("Cloning group '{}'", gitlabGroup); + + final Either maybeGroup = gitlabService.findGroupBy(gitlabGroup, searchMode); + if (maybeGroup.isLeft()) { + log.info("Could not find group '{}': {}", gitlabGroup, maybeGroup.getLeft()); + return; + } + + final GitlabGroup group = maybeGroup.get(); + log.debug("Found group = {}", group); + + final Flowable>> clonedProjects = + gitlabService.getGitlabGroupProjects(group) + .map(project -> Tuple.of(project, project)) + .map(tuple -> tuple.map2( + project -> recurseSubmodules + ? gitService.cloneOrInitSubmodulesProject(project, localPath) + : gitService.cloneProject(project, localPath) + ) + ); + + clonedProjects.blockingIterable() + .forEach(tuple -> { + final GitlabProject project = tuple._1; + final Either gitRepoOrError = tuple._2; + if (gitRepoOrError.isLeft()) { + log.warn("Git operation failed", gitRepoOrError.getLeft()); + } else { + log.info("Project '{}' updated.", project.getNameWithNamespace()); + } + }); + + log.info("All done"); + } +} diff --git a/src/main/java/devex/GitlabCommand.java b/src/main/java/devex/GitlabCommand.java new file mode 100644 index 0000000..70b506e --- /dev/null +++ b/src/main/java/devex/GitlabCommand.java @@ -0,0 +1,20 @@ +package devex; + +import picocli.CommandLine; +import picocli.CommandLine.Command; + +@Command( + name = "gitlab", + aliases = "gl", + subcommands = {GitlabCloneCommand.class}, + descriptionHeading = "%nGitLab configuration:%n%n", + description = { + "The GitLab URL and private token are read from the environment, using GITLAB_URL and GITLAB_TOKEN variables.", + "GITLAB_URL defaults to 'https://gitlab.com'.", + "The GitLab token is used for both querying the GitLab API and discover the group to clone and as the password for cloning using HTTPS.", + "No token is needed for public groups and repositories." + }, + scope = CommandLine.ScopeType.INHERIT +) +public class GitlabCommand { +} diff --git a/src/main/java/devex/LoggingConfiguration.java b/src/main/java/devex/LoggingConfiguration.java new file mode 100644 index 0000000..6ea7811 --- /dev/null +++ b/src/main/java/devex/LoggingConfiguration.java @@ -0,0 +1,63 @@ +package devex; + +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.joran.JoranConfigurator; +import ch.qos.logback.core.joran.spi.JoranException; +import io.micronaut.logging.LogLevel; +import io.micronaut.logging.LoggingSystem; +import org.slf4j.LoggerFactory; + +import java.util.Arrays; + +public class LoggingConfiguration { + + public static final String APPLICATION_LOGGER = "devex"; + + public static final String[] ALL_LOGGERS = { + APPLICATION_LOGGER, + "io.micronaut.context.env", + "io.micronaut.http.client", + "io.netty.handler.ssl", + "org.eclipse.jgit.util", + "org.eclipse.jgit.submodule", + "org.eclipse.jgit.storage", + "org.eclipse.jgit.gitrepo", + "org.eclipse.jgit.events", + "org.eclipse.jgit.api", + "org.eclipse.jgit.errors", + "org.eclipse.jgit.transport", + }; + + public static void configureLoggers(LoggingSystem loggingSystem, LogLevel level, boolean fullLogs) throws JoranException { + if (fullLogs) { + loadFullLogsConfig(); + configureAllLoggers(loggingSystem, level); + } else { + configureApplicationLoggers(loggingSystem, level); + } + } + + private static void configureAllLoggers(LoggingSystem loggingSystem, LogLevel level) { + Arrays.stream(ALL_LOGGERS).forEach(logger -> loggingSystem.setLogLevel(logger, level)); + } + + public static void configureApplicationLoggers(LoggingSystem loggingSystem, LogLevel level) { + loggingSystem.setLogLevel(APPLICATION_LOGGER, level); + } + + public static void loadLogsConfig() throws JoranException { + LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory(); + context.reset(); + JoranConfigurator configurator = new JoranConfigurator(); + configurator.setContext(context); + configurator.doConfigure(LoggingConfiguration.class.getResourceAsStream("/logback.xml")); + } + + public static void loadFullLogsConfig() throws JoranException { + LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory(); + context.reset(); + JoranConfigurator configurator = new JoranConfigurator(); + configurator.setContext(context); + configurator.doConfigure(LoggingConfiguration.class.getResourceAsStream("/logback-full-logs.xml")); + } +} diff --git a/src/main/java/devex/git/GitCloneProtocol.java b/src/main/java/devex/git/GitCloneProtocol.java new file mode 100644 index 0000000..6a3f489 --- /dev/null +++ b/src/main/java/devex/git/GitCloneProtocol.java @@ -0,0 +1,5 @@ +package devex.git; + +public enum GitCloneProtocol { + SSH, HTTPS +} diff --git a/src/main/java/devex/git/GitService.java b/src/main/java/devex/git/GitService.java new file mode 100644 index 0000000..83658d6 --- /dev/null +++ b/src/main/java/devex/git/GitService.java @@ -0,0 +1,125 @@ +package devex.git; + +import devex.gitlab.GitlabProject; +import io.micronaut.context.annotation.Value; +import io.vavr.control.Either; +import io.vavr.control.Try; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.jgit.api.CloneCommand; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.transport.SshSessionFactory; +import org.eclipse.jgit.transport.SshTransport; +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; + +import javax.inject.Singleton; +import java.io.File; +import java.io.IOException; +import java.nio.file.FileSystems; +import java.util.Objects; + +@Slf4j +@Singleton +public class GitService { + + private static final SshSessionFactory sshSessionFactory = new OverrideJschConfigSessionFactory(); + + private GitCloneProtocol cloneProtocol = GitCloneProtocol.SSH; + private String httpsUsername = ""; + @Value("${gitlab.token:}") + private String httpsPassword = ""; + + public void setCloneProtocol(GitCloneProtocol cloneProtocol) { + this.cloneProtocol = cloneProtocol; + } + + public void setHttpsUsername(String httpsUsername) { + this.httpsUsername = httpsUsername; + } + + protected void setHttpsPassword(String httpsPassword) { + this.httpsPassword = httpsPassword; + } + + public Either cloneOrInitSubmodulesProject(GitlabProject project, String cloneDirectory) { + final String projectName = project.getNameWithNamespace(); + log.trace("Cloning or initializing submodules for project '{}' under directory '{}'", projectName, cloneDirectory); + return tryCloneProject(project, cloneDirectory, projectName, true) + .recoverWith(t -> tryInitSubmodules(project, cloneDirectory)) + .toEither(); + } + + public Either cloneProject(final GitlabProject project, final String cloneDirectory) { + final String projectName = project.getNameWithNamespace(); + log.trace("Cloning project '{}' under directory '{}'", projectName, cloneDirectory); + return tryCloneProject(project, cloneDirectory, projectName, false) + .toEither(); + } + + private Try tryCloneProject(final GitlabProject project, final String cloneDirectory, final String projectName, final boolean cloneSubmodules) { + return Try.of(() -> cloneProject(project, cloneDirectory, cloneSubmodules)) + .onSuccess(gitRepo -> log.trace("Cloned project '{}' to '{}'", projectName, getDirectory(gitRepo))) + .onFailure(t -> logFailedClone(projectName, t)); + } + + private Try tryInitSubmodules(GitlabProject project, String cloneDirectory) { + return Try.of(() -> initSubmodules(project, cloneDirectory)) + .onSuccess(gitRepo -> log.trace("Initialized submodules of git repository at '{}'", getDirectory(gitRepo))) + .onFailure(t2 -> logFailedSubmoduleInit(project.getName(), t2)); + } + + protected Git openRepository(GitlabProject project, String cloneDirectory) throws IOException { + String pathToRepo = cloneDirectory + FileSystems.getDefault().getSeparator() + project.getPathWithNamespace(); + return Git.open(new File(pathToRepo)); + } + + private void logFailedClone(String projectName, Throwable throwable) { + log.debug(String.format("Could not clone project '%s' because: %s", projectName, throwable.getMessage()), throwable); + } + + private String getDirectory(Git gitRepo) { + return gitRepo.getRepository().getDirectory().toString(); + } + + protected Git cloneProject(GitlabProject project, String cloneDirectory, boolean cloneSubmodules) throws GitAPIException { + String pathToClone = cloneDirectory + FileSystems.getDefault().getSeparator() + project.getPathWithNamespace(); + + final CloneCommand cloneCommand = Git.cloneRepository(); + switch (cloneProtocol) { + case SSH: + cloneCommand.setURI(project.getSshUrlToRepo()); + cloneCommand.setTransportConfigCallback(transport -> { + SshTransport sshTransport = (SshTransport) transport; + sshTransport.setSshSessionFactory(sshSessionFactory); + }); + break; + case HTTPS: + cloneCommand.setURI(project.getHttpUrlToRepo()); + final String username = Objects.requireNonNullElse(httpsUsername, ""); + final String password = Objects.requireNonNullElse(httpsPassword, ""); + if (!username.isBlank() && !password.isBlank()) { + cloneCommand.setCredentialsProvider(new UsernamePasswordCredentialsProvider(httpsUsername, httpsPassword)); + } else { + log.debug("Credentials for HTTPS remote not set, group to clone must be public."); + } + break; + } + cloneCommand.setDirectory(new File(pathToClone)); + cloneCommand.setCloneSubmodules(cloneSubmodules); + cloneCommand.setCloneAllBranches(false); + cloneCommand.setNoTags(); + + return cloneCommand.call(); + } + + protected Git initSubmodules(GitlabProject project, String cloneDirectory) throws IOException, GitAPIException { + final Git repo = openRepository(project, cloneDirectory); + repo.submoduleInit().call(); + repo.submoduleUpdate().call(); + return repo; + } + + private void logFailedSubmoduleInit(String projectName, Throwable throwable) { + log.debug(String.format("Could not initialize submodules for project '%s' because: %s", projectName, throwable.getMessage()), throwable); + } +} diff --git a/src/main/java/devex/git/OverrideJschConfigSessionFactory.java b/src/main/java/devex/git/OverrideJschConfigSessionFactory.java new file mode 100644 index 0000000..23b8fc4 --- /dev/null +++ b/src/main/java/devex/git/OverrideJschConfigSessionFactory.java @@ -0,0 +1,42 @@ +package devex.git; + +import com.jcraft.jsch.JSch; +import com.jcraft.jsch.JSchException; +import com.jcraft.jsch.OpenSSHConfig; +import com.jcraft.jsch.Session; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.jgit.transport.JschConfigSessionFactory; +import org.eclipse.jgit.transport.OpenSshConfig; +import org.eclipse.jgit.util.FS; + +import java.io.IOException; +import java.nio.file.FileSystems; +import java.util.Objects; + +@Slf4j +public class OverrideJschConfigSessionFactory extends JschConfigSessionFactory { + static { + JSch.setConfig("signature.rsa", JSch.getConfig("ssh-rsa")); + } + + @Override + protected JSch getJSch(OpenSshConfig.Host hc, FS fs) throws JSchException { + final JSch jSch = super.getJSch(hc, fs); + final String separator = FileSystems.getDefault().getSeparator(); + String configFilePath = Objects.requireNonNullElse(System.getProperty("user.home"), ".") + separator + ".ssh" + separator + "config"; + try { + // jGit uses a patched config repository (in super.getJSch(hc, fs)) that prevents proper loading of the ssh config, + // so, we re-set it to an implementation that works + // patched repository: org.eclipse.jgit.transport.JschBugFixingConfigRepository + jSch.setConfigRepository(OpenSSHConfig.parseFile(configFilePath)); + } catch (IOException e) { + log.warn("Could not load SSH config file at " + configFilePath, e); + } + return jSch; + } + + @Override + protected void configure(OpenSshConfig.Host hc, Session session) { + session.setConfig("PreferredAuthentications", "publickey"); + } +} diff --git a/src/main/java/devex/gitlab/GitlabClient.java b/src/main/java/devex/gitlab/GitlabClient.java new file mode 100644 index 0000000..14f99cc --- /dev/null +++ b/src/main/java/devex/gitlab/GitlabClient.java @@ -0,0 +1,59 @@ +package devex.gitlab; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.Header; +import io.micronaut.http.annotation.PathVariable; +import io.micronaut.http.annotation.QueryValue; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.retry.annotation.Retryable; +import io.reactivex.Flowable; + +import java.util.List; +import java.util.Optional; + +@Retryable(excludes = HttpClientResponseException.class) +@Client("${gitlab.url}/api/v4") +@Header(name = GitlabClient.H_PRIVATE_TOKEN, value = "${gitlab.token:}") +public interface GitlabClient { + String H_PRIVATE_TOKEN = "PRIVATE-TOKEN"; + + @Get("/groups{?search,per_page,all_available,page}") + Flowable>> searchGroups( + @QueryValue String search, + @QueryValue(value = "all_available") boolean allAvailable, + @QueryValue(value = "per_page") int perPage, + @QueryValue int page + ); + + @Get("/groups/{id}") + Optional getGroup(@PathVariable String id); + + @Get("/groups/{id}/subgroups{?all_available,per_page,page}") + Flowable>> groupSubGroups( + @PathVariable String id, + @QueryValue(value = "all_available") boolean allAvailable, + @QueryValue(value = "per_page") int perPage, + @QueryValue int page + ); + + @Get("/groups/{id}/descendant_groups{?all_available,per_page,page}") + Flowable>> groupDescendants( + @PathVariable String id, + @QueryValue(value = "all_available") boolean allAvailable, + @QueryValue(value = "per_page") int perPage, + @QueryValue int page + ); + + @Get("/groups/{id}/projects") + Flowable>> groupProjects( + @PathVariable String id, + @QueryValue(value = "all_available") boolean allAvailable, + @QueryValue(value = "per_page") int perPage, + @QueryValue int page + ); + + @Get("/version") + GitlabVersion version(); +} diff --git a/src/main/java/devex/gitlab/GitlabGroup.java b/src/main/java/devex/gitlab/GitlabGroup.java new file mode 100644 index 0000000..8fb7c00 --- /dev/null +++ b/src/main/java/devex/gitlab/GitlabGroup.java @@ -0,0 +1,52 @@ +package devex.gitlab; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.micronaut.core.annotation.Introspected; +import lombok.ToString; + +@Introspected +@ToString +public class GitlabGroup { + private String id; + private String name; + private String path; + @JsonProperty("full_path") + String fullPath; + + @JsonCreator + public GitlabGroup() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getFullPath() { + return fullPath; + } + + public void setFullPath(String fullPath) { + this.fullPath = fullPath; + } +} diff --git a/src/main/java/devex/gitlab/GitlabGroupSearchMode.java b/src/main/java/devex/gitlab/GitlabGroupSearchMode.java new file mode 100644 index 0000000..a1fd073 --- /dev/null +++ b/src/main/java/devex/gitlab/GitlabGroupSearchMode.java @@ -0,0 +1,31 @@ +package devex.gitlab; + +import java.util.function.Predicate; + +public enum GitlabGroupSearchMode { + NAME, FULL_PATH, ID; + + public String textualQualifier() { + switch (this) { + case NAME: + return "named"; + case FULL_PATH: + return "with full path"; + case ID: + return "identified by"; + } + throw new IllegalArgumentException("There is no qualifier defined for " + this); + } + + public Predicate groupPredicate(String search) { + switch (this) { + case NAME: + return group -> group.getName().equalsIgnoreCase(search); + case FULL_PATH: + return group -> group.getFullPath().equalsIgnoreCase(search); + case ID: + return group -> group.getId().equalsIgnoreCase(search); + } + throw new IllegalArgumentException("There is no predicate defined for " + this); + } +} diff --git a/src/main/java/devex/gitlab/GitlabProject.java b/src/main/java/devex/gitlab/GitlabProject.java new file mode 100644 index 0000000..091a51c --- /dev/null +++ b/src/main/java/devex/gitlab/GitlabProject.java @@ -0,0 +1,86 @@ +package devex.gitlab; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.micronaut.core.annotation.Introspected; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.ToString; + +@Introspected +@Builder +@AllArgsConstructor +@ToString +public class GitlabProject { + String id; + String name; + @JsonProperty("name_with_namespace") + String nameWithNamespace; + String path; + @JsonProperty("path_with_namespace") + String pathWithNamespace; + @JsonProperty("ssh_url_to_repo") + String sshUrlToRepo; + @JsonProperty("http_url_to_repo") + String httpUrlToRepo; + + @JsonCreator + public GitlabProject() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getNameWithNamespace() { + return nameWithNamespace; + } + + public void setNameWithNamespace(String nameWithNamespace) { + this.nameWithNamespace = nameWithNamespace; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getPathWithNamespace() { + return pathWithNamespace; + } + + public void setPathWithNamespace(String pathWithNamespace) { + this.pathWithNamespace = pathWithNamespace; + } + + public String getSshUrlToRepo() { + return sshUrlToRepo; + } + + public void setSshUrlToRepo(String sshUrlToRepo) { + this.sshUrlToRepo = sshUrlToRepo; + } + + public String getHttpUrlToRepo() { + return httpUrlToRepo; + } + + public void setHttpUrlToRepo(String httpUrlToRepo) { + this.httpUrlToRepo = httpUrlToRepo; + } +} diff --git a/src/main/java/devex/gitlab/GitlabService.java b/src/main/java/devex/gitlab/GitlabService.java new file mode 100644 index 0000000..de60323 --- /dev/null +++ b/src/main/java/devex/gitlab/GitlabService.java @@ -0,0 +1,144 @@ +package devex.gitlab; + +import io.micronaut.context.annotation.Value; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.client.exceptions.HttpClientException; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.reactivex.Flowable; +import io.vavr.control.Either; +import io.vavr.control.Option; +import lombok.extern.slf4j.Slf4j; + +import javax.inject.Singleton; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +@Slf4j +@Singleton +public class GitlabService { + + public static final int MAX_ELEMENTS_PER_PAGE = 10; + public static final String RESPONSE_HEADER_NEXT_PAGE = "X-Next-Page"; + public static final String GROUP_DESCENDANTS_VERSION = "13.5"; + public static final String GROUP_NOT_FOUND = "Group not found"; + private final GitlabClient client; + private final String gitlabUrl; + + public GitlabService(GitlabClient client, @Value("${gitlab.url}") String gitlabUrl) { + this.client = client; + this.gitlabUrl = gitlabUrl; + } + + public Either findGroupBy(String search, GitlabGroupSearchMode by) { + log.debug("Looking for group {}: {}", by.textualQualifier(), search); + if (by == GitlabGroupSearchMode.ID) { + return getGroup(search); + } else { + Function>>> apiCall = pageIndex -> client.searchGroups(search, true, MAX_ELEMENTS_PER_PAGE, pageIndex); + final Flowable results = paginatedApiCall(apiCall); + return results.filter(gitlabGroup -> by.groupPredicate(search).test(gitlabGroup)) + .map(Either::right).blockingFirst(Either.left(GROUP_NOT_FOUND)); + } + } + + public Either getGroup(String id) { + try { + return Option.ofOptional(client.getGroup(id)).toEither(GROUP_NOT_FOUND); + } catch (HttpClientResponseException e) { + final HttpStatus status = e.getStatus(); + log.warn("Unexpected status {} fetching GitLab group {}: {}, ", status.getCode(), id, status.getReason()); + return Either.left(e.getMessage()); + } + } + + public Flowable getGitlabGroupProjects(GitlabGroup group) { + log.debug("Searching for projects in group '{}'", group.getFullPath()); + final String groupId = group.getId(); + final Flowable projects = getGroupProjects(groupId); + final Flowable subGroups = getSubGroups(groupId); + return Flowable.mergeDelayError(projects, subGroups.flatMap(subGroup -> getGroupProjects(subGroup.getId()))); + } + + protected Flowable getSubGroups(String groupId) { + log.trace("Retrieving sub-groups of '{}'", groupId); + final Option maybeVersion = getVersion(); + if (maybeVersion.isDefined()) { + final GitlabVersion gitlabVersion = maybeVersion.get(); + if (gitlabVersion.isBefore(GROUP_DESCENDANTS_VERSION)) { + log.trace("Retrieving sib-groups recursively because GitLab server version is '{}'", gitlabVersion.getVersion()); + return getSubGroupsRecursively(groupId); + } + } else { + log.trace("Could not get GitLab server version, defaulting to retrieving sub-groups with descendant API."); + } + return getDescendantGroups(groupId); + } + + private Option getVersion() { + try { + final GitlabVersion version = client.version(); + log.debug("GitLab server at '{}' is running version '{}'", gitlabUrl, version); + return Option.of(version); + } catch (HttpClientResponseException e) { + final HttpStatus status = e.getStatus(); + if (status.equals(HttpStatus.UNAUTHORIZED)) { + log.debug("Could not detect GitLab server version without a valid token."); + } else { + log.warn("Unexpected status {} checking GitLab version: {}, ", status.getCode(), status.getReason()); + } + } + return Option.none(); + } + + protected Flowable getDescendantGroups(String groupId) { + return paginatedApiCall(pageIndex -> client.groupDescendants(groupId, true, MAX_ELEMENTS_PER_PAGE, pageIndex)); + } + + protected Flowable getSubGroupsRecursively(String groupId) { + final Flowable subGroups = paginatedApiCall(pageIndex -> client.groupSubGroups(groupId, true, MAX_ELEMENTS_PER_PAGE, pageIndex)); + return subGroups.flatMap(group -> Flowable.just(group).mergeWith(getSubGroupsRecursively(group.getId()))); + } + + private Flowable getGroupProjects(String groupId) { + log.trace("Retrieving group '{}' projects", groupId); + return paginatedApiCall(pageIndex -> client.groupProjects(groupId, true, MAX_ELEMENTS_PER_PAGE, pageIndex)); + } + + private Flowable paginatedApiCall(final Function>>> apiCall) { + log.trace("Invoking paginated API"); + final Flowable>> responses = callPage(apiCall, 1); + return responses.map(response -> Option.of(response.body())) + .filter(Option::isDefined) + .map(Option::get) + .flatMap(Flowable::fromIterable); + } + + private Flowable> callPage(Function>> apiCall, int pageIndex) { + try { + log.trace("Calling page {}", pageIndex); + return apiCall.apply(pageIndex) + .flatMap(response -> { + final String nextPageHeader = + Objects.requireNonNullElse( + response.getHeaders().get(RESPONSE_HEADER_NEXT_PAGE), + "0" + ); + int nextPageIndex; + final Flowable> nextCall; + if (!nextPageHeader.isBlank() && (nextPageIndex = Integer.parseInt(nextPageHeader)) > 1) { + log.trace("Next page is {}", nextPageIndex); + nextCall = callPage(apiCall, nextPageIndex); + } else { + log.trace("No more pages"); + nextCall = Flowable.empty(); + } + return Flowable.just(response).mergeWith(nextCall); + }); + } catch (HttpClientException e) { + log.error("GitLab API call failed: {}", e.getMessage()); + return Flowable.empty(); + } + } +} diff --git a/src/main/java/devex/gitlab/GitlabVersion.java b/src/main/java/devex/gitlab/GitlabVersion.java new file mode 100644 index 0000000..f7e4c36 --- /dev/null +++ b/src/main/java/devex/gitlab/GitlabVersion.java @@ -0,0 +1,47 @@ +package devex.gitlab; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.core.Version; +import com.fasterxml.jackson.core.util.VersionUtil; +import io.micronaut.core.annotation.Introspected; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Introspected +@Data +@AllArgsConstructor +public class GitlabVersion { + String version; + String revision; + + @JsonCreator + public GitlabVersion() { + } + + public boolean isBefore(String lowerBound) { + return compare(lowerBound) < 0; + } + + int compare(String lowerBound) { + final Version lowerBoundVersion = parse(lowerBound); + final Version thisVersion = parse(version); + + return thisVersion.compareTo(lowerBoundVersion); + } + + static Version parse(String text) { + final String versionText; + if (text.contains("-")) { + final String[] mainSplit = text.split("-"); + versionText = mainSplit[0]; + } else { + versionText = text; + } + return VersionUtil.parseVersion(versionText, null, null); + } + + @Override + public String toString() { + return "v" + version + " at rev " + revision; + } +} diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java new file mode 100644 index 0000000..0984f7d --- /dev/null +++ b/src/main/java/module-info.java @@ -0,0 +1,25 @@ +module devex { + requires static lombok; + requires logback.classic; + requires org.slf4j; + requires com.fasterxml.jackson.databind; + requires io.vavr; + requires io.micronaut.http_client; + requires io.micronaut.runtime; + requires io.micronaut.inject; + requires io.micronaut.http; + requires io.micronaut.http_client_core; + requires io.reactivex.rxjava2; + requires javax.inject; + requires info.picocli; + requires org.eclipse.jgit; + // merged with org.eclipse.jgit to prevent split package + // requires org.eclipse.jgit.ssh.jsch; + requires jsch; + requires io.micronaut.core; + requires org.reactivestreams; + requires io.micronaut.picocli.picocli; + requires logback.core; + + exports devex; +} diff --git a/src/main/resources/META-INF/native-image/jni-config.json b/src/main/resources/META-INF/native-image/jni-config.json new file mode 100644 index 0000000..3541077 --- /dev/null +++ b/src/main/resources/META-INF/native-image/jni-config.json @@ -0,0 +1,19 @@ +[ +{ + "name":"java.lang.ClassLoader", + "methods":[{"name":"getPlatformClassLoader","parameterTypes":[] }] +}, +{ + "name":"sun.management.VMManagementImpl", + "fields":[ + {"name":"compTimeMonitoringSupport"}, + {"name":"currentThreadCpuTimeSupport"}, + {"name":"objectMonitorUsageSupport"}, + {"name":"otherThreadCpuTimeSupport"}, + {"name":"remoteDiagnosticCommandsSupport"}, + {"name":"synchronizerUsageSupport"}, + {"name":"threadAllocatedMemorySupport"}, + {"name":"threadContentionMonitoringSupport"} + ] +} +] diff --git a/src/main/resources/META-INF/native-image/proxy-config.json b/src/main/resources/META-INF/native-image/proxy-config.json new file mode 100644 index 0000000..0d4f101 --- /dev/null +++ b/src/main/resources/META-INF/native-image/proxy-config.json @@ -0,0 +1,2 @@ +[ +] diff --git a/src/main/resources/META-INF/native-image/reflect-config.json b/src/main/resources/META-INF/native-image/reflect-config.json new file mode 100644 index 0000000..64786d2 --- /dev/null +++ b/src/main/resources/META-INF/native-image/reflect-config.json @@ -0,0 +1,1636 @@ +[ +{ + "name":"byte[]" +}, +{ + "name":"ch.qos.logback.classic.encoder.PatternLayoutEncoder", + "allPublicMethods":true, + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"ch.qos.logback.classic.pattern.DateConverter", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"ch.qos.logback.classic.pattern.LevelConverter", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"ch.qos.logback.classic.pattern.LineSeparatorConverter", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"ch.qos.logback.classic.pattern.LoggerConverter", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"ch.qos.logback.classic.pattern.MessageConverter", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"ch.qos.logback.classic.pattern.NopThrowableInformationConverter", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"ch.qos.logback.classic.pattern.ThreadConverter", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"ch.qos.logback.classic.pattern.color.HighlightingCompositeConverter", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"ch.qos.logback.core.ConsoleAppender", + "allPublicMethods":true, + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.fasterxml.jackson.databind.JsonDeserializer[]" +}, +{ + "name":"com.fasterxml.jackson.databind.JsonSerializer[]" +}, +{ + "name":"com.fasterxml.jackson.databind.KeyDeserializer[]" +}, +{ + "name":"com.fasterxml.jackson.databind.Module[]" +}, +{ + "name":"com.fasterxml.jackson.databind.deser.BeanDeserializerModifier[]" +}, +{ + "name":"com.fasterxml.jackson.databind.deser.Deserializers[]" +}, +{ + "name":"com.fasterxml.jackson.databind.deser.KeyDeserializers[]" +}, +{ + "name":"com.fasterxml.jackson.databind.deser.ValueInstantiators[]" +}, +{ + "name":"com.fasterxml.jackson.databind.ext.Java7HandlersImpl", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.fasterxml.jackson.databind.ext.Java7SupportImpl", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.fasterxml.jackson.databind.ser.BeanSerializerModifier[]" +}, +{ + "name":"com.fasterxml.jackson.databind.ser.Serializers[]" +}, +{ + "name":"com.jcraft.jsch.DH25519", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.DH448", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.DHEC256", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.DHEC384", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.DHEC521", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.DHG14", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.DHG14256", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.DHG15", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.DHG16", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.DHG17", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.DHG18", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.UserAuthNone", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.UserAuthPublicKey", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.jce.AES128CTR", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.jce.AES128GCM", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.jce.AES192CTR", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.jce.AES256CTR", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.jce.AES256GCM", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.jce.DH", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.jce.ECDHN", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.jce.HMACSHA1", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.jce.HMACSHA256", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.jce.HMACSHA256ETM", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.jce.HMACSHA512", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.jce.HMACSHA512ETM", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.jce.Random", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.jce.SHA1", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.jce.SHA256", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.jce.SHA384", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.jce.SHA512", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.jce.SignatureECDSA256", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.jce.SignatureECDSA384", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.jce.SignatureECDSA521", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.jce.SignatureRSASHA256", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.jcraft.jsch.jce.SignatureRSASHA512", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.sun.crypto.provider.AESCipher$General", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.sun.crypto.provider.DHKeyAgreement", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.sun.crypto.provider.DHKeyPairGenerator", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.sun.crypto.provider.DHParameters", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.sun.crypto.provider.HmacCore$HmacSHA256", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.sun.crypto.provider.HmacCore$HmacSHA512", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.sun.crypto.provider.TlsMasterSecretGenerator", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.sun.org.apache.xerces.internal.jaxp.SAXParserFactoryImpl", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"devex.$DevexCommandDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"devex.$GitlabCloneCommandDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"devex.DevexCommand", + "allDeclaredFields":true, + "allDeclaredMethods":true +}, +{ + "name":"devex.DevexCommand$AppVersionProvider", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"devex.GitlabCloneCommand", + "allDeclaredFields":true, + "allDeclaredMethods":true +}, +{ + "name":"devex.GitlabCommand", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"devex.git.$GitServiceDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"devex.git.GitService", + "fields":[{"name":"httpsPassword"}] +}, +{ + "name":"devex.gitlab.$GitlabClient$InterceptedDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"devex.gitlab.$GitlabGroup$IntrospectionRef", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"devex.gitlab.$GitlabProject$IntrospectionRef", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"devex.gitlab.$GitlabServiceDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"devex.gitlab.$GitlabVersion$IntrospectionRef", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"devex.gitlab.GitlabGroup", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"devex.gitlab.GitlabProject", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"devex.gitlab.GitlabVersion", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"io.micronaut.aop.Interceptor[]" +}, +{ + "name":"io.micronaut.buffer.netty.$NettyByteBufferFactoryDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.context.env.PropertiesPropertySourceLoader", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.context.env.yaml.YamlPropertySourceLoader", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.core.async.publisher.Publishers" +}, +{ + "name":"io.micronaut.discovery.$DefaultCompositeDiscoveryClientDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.discovery.$DefaultServiceInstanceIdGeneratorDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.discovery.cloud.digitalocean.$DigitalOceanInstanceMetadata$IntrospectionRef", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.discovery.cloud.digitalocean.$DigitalOceanMetadataConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.discovery.cloud.digitalocean.$DigitalOceanMetadataResolverDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.discovery.config.$DefaultCompositeConfigurationClientDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.health.$DefaultCurrentHealthStatusDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.health.$HealthStatus$IntrospectionRef", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.health.$HeartbeatConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.health.$HeartbeatDiscoveryClientCondition$IntrospectionRef", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.health.$HeartbeatTaskDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.bind.$DefaultRequestBinderRegistryDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.$DefaultHttpClientConfiguration$DefaultConnectionPoolConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.$DefaultHttpClientConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.$DefaultLoadBalancerResolverDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.$ServiceHttpClientCondition$IntrospectionRef", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.$ServiceHttpClientConfiguration$ServiceConnectionPoolConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.$ServiceHttpClientConfiguration$ServiceSslClientConfiguration$DefaultKeyConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.$ServiceHttpClientConfiguration$ServiceSslClientConfiguration$DefaultKeyStoreConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.$ServiceHttpClientConfiguration$ServiceSslClientConfiguration$DefaultTrustStoreConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.$ServiceHttpClientConfiguration$ServiceSslClientConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.$ServiceHttpClientConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.$ServiceHttpClientFactory$HealthCheckStarter1DefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.$ServiceHttpClientFactory$ServiceInstanceList0DefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.$ServiceHttpClientFactoryDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.bind.$DefaultHttpClientBinderRegistryDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.filter.$DefaultHttpClientFilterResolverDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.interceptor.$HttpClientIntroductionAdviceDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.interceptor.configuration.$DefaultClientVersioningConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.interceptor.configuration.$NamedClientVersioningConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.loadbalance.$DiscoveryClientLoadBalancerFactoryDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.loadbalance.$LoadBalancerConvertersDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.loadbalance.$ServiceInstanceListLoadBalancerFactoryDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.netty.$RxNettyHttpClientRegistry$HttpClient0DefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.netty.$RxNettyHttpClientRegistryDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.netty.DefaultHttpClient$12", + "methods":[{"name":"exceptionCaught","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.lang.Throwable"] }] +}, +{ + "name":"io.micronaut.http.client.netty.DefaultHttpClient$16$1" +}, +{ + "name":"io.micronaut.http.client.netty.DefaultHttpClient$8" +}, +{ + "name":"io.micronaut.http.client.netty.DefaultHttpClient$Http2SettingsHandler" +}, +{ + "name":"io.micronaut.http.client.netty.IdlingConnectionHandler", + "methods":[ + {"name":"channelRead","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.lang.Object"] }, + {"name":"exceptionCaught","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.lang.Throwable"] } + ] +}, +{ + "name":"io.micronaut.http.client.netty.NettyClientHttpRequestFactory", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.netty.ssl.$NettyClientSslBuilderDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.client.rxjava2.$RxReactiveClientResultTransformerDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.codec.$CodecConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.converters.$HttpConverterRegistrarDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.hateoas.$AbstractResource$IntrospectionRef", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.hateoas.$DefaultLink$IntrospectionRef", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.hateoas.$JsonError$IntrospectionRef", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.hateoas.$VndError$IntrospectionRef", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.hateoas.AbstractResource", + "allDeclaredFields":true, + "allDeclaredMethods":true +}, +{ + "name":"io.micronaut.http.hateoas.JsonError", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true +}, +{ + "name":"io.micronaut.http.hateoas.Resource", + "allDeclaredMethods":true +}, +{ + "name":"io.micronaut.http.netty.channel.$DefaultEventLoopGroupConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.netty.channel.$DefaultEventLoopGroupFactoryDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.netty.channel.$DefaultEventLoopGroupRegistry$DefaultEventLoopGroup1DefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.netty.channel.$DefaultEventLoopGroupRegistry$EventLoopGroup0DefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.netty.channel.$DefaultEventLoopGroupRegistryDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.netty.channel.$EpollAvailabilityCondition$IntrospectionRef", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.netty.channel.$EpollEventLoopGroupFactoryDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.netty.channel.$KQueueAvailabilityCondition$IntrospectionRef", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.netty.channel.$KQueueEventLoopGroupFactoryDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.netty.channel.$NettyThreadFactory$NettyThreadFactory0DefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.netty.channel.$NettyThreadFactoryDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.netty.channel.$NioEventLoopGroupFactoryDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.netty.channel.converters.$DefaultChannelOptionFactoryDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.netty.channel.converters.$EpollChannelOptionFactoryDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.netty.channel.converters.$KQueueChannelOptionFactoryDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.netty.configuration.$NettyGlobalConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.netty.websocket.$NettyServerWebSocketBroadcasterDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.netty.websocket.$WebSocketMessageEncoderDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.resource.$ResourceLoaderFactory$FileSystemResourceLoader1DefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.resource.$ResourceLoaderFactory$GetClassPathResourceLoader0DefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.resource.$ResourceLoaderFactory$ResourceResolver2DefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.resource.$ResourceLoaderFactoryDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.ssl.$ClientSslConfiguration$DefaultKeyConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.ssl.$ClientSslConfiguration$DefaultKeyStoreConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.ssl.$ClientSslConfiguration$DefaultTrustStoreConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.ssl.$ClientSslConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.ssl.$DefaultSslConfiguration$DefaultKeyConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.ssl.$DefaultSslConfiguration$DefaultKeyStoreConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.ssl.$DefaultSslConfiguration$DefaultTrustStoreConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.ssl.$DefaultSslConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.ssl.$ServerSslConfiguration$DefaultKeyConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.ssl.$ServerSslConfiguration$DefaultKeyStoreConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.ssl.$ServerSslConfiguration$DefaultTrustStoreConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.ssl.$ServerSslConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.http.util.$OutgoingHttpRequestProcessorImplDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.jackson.$BeanConfiguration", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.jackson.$JacksonConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.jackson.$ObjectMapperFactory$ObjectMapper0DefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.jackson.$ObjectMapperFactoryDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.jackson.bind.$JacksonBeanPropertyBinderDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.jackson.codec.$JsonMediaTypeCodecDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.jackson.codec.$JsonStreamMediaTypeCodecDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.jackson.convert.$JacksonConverterRegistrarDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.jackson.env.CloudFoundryVcapApplicationPropertySourceLoader", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.jackson.env.CloudFoundryVcapServicesPropertySourceLoader", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.jackson.env.EnvJsonPropertySourceLoader", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.jackson.env.JsonPropertySourceLoader", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.jackson.modules.$BeanIntrospectionModuleDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.jackson.serialize.$ConvertibleMultiValuesSerializerDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.jackson.serialize.$ConvertibleValuesSerializerDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.jackson.serialize.$JacksonObjectSerializerDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.jackson.serialize.$OptionalValuesSerializerDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.jackson.serialize.$ResourceModuleDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.jackson.serialize.$ResourceSerializerModifierDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.logging.$LogLevel$IntrospectionRef", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.logging.$PropertiesLoggingLevelsConfigurerDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.logging.impl.$Log4jLoggingSystemDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.logging.impl.$LogbackLoggingSystemDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.reactive.flow.converters.$FlowConverterRegistrarDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.reactive.rxjava2.$RxInstrumenterFactoryDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.reactive.rxjava2.$RxJava2InstrumentationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.reactive.rxjava2.converters.$RxJavaConverterRegistrarDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.retry.annotation.$DefaultRetryPredicate$IntrospectionRef", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.retry.intercept.$DefaultRetryInterceptorDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.retry.intercept.$RecoveryInterceptorDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.runtime.$ApplicationConfiguration$InstanceConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.runtime.$ApplicationConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.runtime.context.$CompositeMessageSourceDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.runtime.context.env.$ConfigurationIntroductionAdviceDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.runtime.context.scope.$ThreadLocalCustomScopeDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.runtime.context.scope.refresh.$RefreshInterceptorDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.runtime.context.scope.refresh.$RefreshScopeDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.runtime.converters.time.$TimeConverterRegistrarDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.runtime.http.codec.$MediaTypeCodecRegistryFactory$MediaTypeCodecRegistry0DefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.runtime.http.codec.$MediaTypeCodecRegistryFactoryDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.runtime.http.codec.$TextPlainCodecDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.runtime.http.scope.$RequestCustomScopeDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.runtime.server.watch.event.$FileWatchRestartListenerDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.scheduling.$DefaultTaskExceptionHandlerDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.scheduling.$ScheduledExecutorTaskSchedulerDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.scheduling.async.$AsyncInterceptorDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.scheduling.executor.$DefaultExecutorSelectorDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.scheduling.executor.$DefaultThreadFactory$ThreadFactory0DefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.scheduling.executor.$DefaultThreadFactoryDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.scheduling.executor.$ExecutorFactory$EventLoopGroupThreadFactory0DefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.scheduling.executor.$ExecutorFactory$ExecutorService1DefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.scheduling.executor.$ExecutorFactoryDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.scheduling.executor.$IOExecutorServiceConfig$Configuration0DefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.scheduling.executor.$IOExecutorServiceConfigDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.scheduling.executor.$ScheduledExecutorServiceConfig$Configuration0DefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.scheduling.executor.$ScheduledExecutorServiceConfigDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.scheduling.executor.$UserExecutorConfiguration$IntrospectionRef", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.scheduling.executor.$UserExecutorConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.scheduling.instrument.$ExecutorServiceInstrumenterDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.scheduling.io.watch.$DefaultWatchThreadDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.scheduling.io.watch.$FileWatchCondition$IntrospectionRef", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.scheduling.io.watch.$FileWatchConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.scheduling.io.watch.$WatchServiceFactory$WatchService0DefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.scheduling.io.watch.$WatchServiceFactoryDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.scheduling.processor.$ScheduledMethodProcessorDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.validation.$ValidatingInterceptorDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.validation.exceptions.$ConstraintExceptionHandlerDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.validation.exceptions.$ValidationExceptionHandlerDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.validation.validator.$DefaultClockProviderDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.validation.validator.$DefaultValidatorConfigurationDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.validation.validator.$DefaultValidatorDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.validation.validator.$DefaultValidatorFactoryDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.validation.validator.constraints.$DefaultConstraintValidators$IntrospectionRef", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.validation.validator.constraints.$DefaultConstraintValidatorsDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.validation.validator.constraints.$EmailValidatorDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.validation.validator.constraints.$PatternValidatorDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.validation.validator.extractors.$DefaultValueExtractors$IntrospectionRef", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.validation.validator.extractors.$DefaultValueExtractorsDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.validation.validator.messages.$DefaultValidationMessagesDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.validation.validator.resolver.$CompositeTraversableResolverDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.micronaut.websocket.interceptor.$ClientWebSocketInterceptorDefinitionClass", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.netty.buffer.AbstractByteBufAllocator", + "allDeclaredMethods":true +}, +{ + "name":"io.netty.buffer.AbstractReferenceCountedByteBuf", + "fields":[{"name":"refCnt"}] +}, +{ + "name":"io.netty.channel.ChannelDuplexHandler", + "methods":[ + {"name":"bind","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.net.SocketAddress","io.netty.channel.ChannelPromise"] }, + {"name":"close","parameterTypes":["io.netty.channel.ChannelHandlerContext","io.netty.channel.ChannelPromise"] }, + {"name":"connect","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.net.SocketAddress","java.net.SocketAddress","io.netty.channel.ChannelPromise"] }, + {"name":"deregister","parameterTypes":["io.netty.channel.ChannelHandlerContext","io.netty.channel.ChannelPromise"] }, + {"name":"disconnect","parameterTypes":["io.netty.channel.ChannelHandlerContext","io.netty.channel.ChannelPromise"] }, + {"name":"flush","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"read","parameterTypes":["io.netty.channel.ChannelHandlerContext"] } + ] +}, +{ + "name":"io.netty.channel.ChannelInboundHandlerAdapter", + "methods":[ + {"name":"channelActive","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"channelInactive","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"channelRead","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.lang.Object"] }, + {"name":"channelReadComplete","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"channelRegistered","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"channelUnregistered","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"channelWritabilityChanged","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"exceptionCaught","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.lang.Throwable"] }, + {"name":"userEventTriggered","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.lang.Object"] } + ] +}, +{ + "name":"io.netty.channel.ChannelInitializer", + "methods":[ + {"name":"channelRegistered","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"exceptionCaught","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.lang.Throwable"] } + ] +}, +{ + "name":"io.netty.channel.DefaultChannelPipeline$HeadContext", + "methods":[ + {"name":"bind","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.net.SocketAddress","io.netty.channel.ChannelPromise"] }, + {"name":"channelActive","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"channelInactive","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"channelRead","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.lang.Object"] }, + {"name":"channelReadComplete","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"channelRegistered","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"channelUnregistered","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"channelWritabilityChanged","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"close","parameterTypes":["io.netty.channel.ChannelHandlerContext","io.netty.channel.ChannelPromise"] }, + {"name":"connect","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.net.SocketAddress","java.net.SocketAddress","io.netty.channel.ChannelPromise"] }, + {"name":"deregister","parameterTypes":["io.netty.channel.ChannelHandlerContext","io.netty.channel.ChannelPromise"] }, + {"name":"disconnect","parameterTypes":["io.netty.channel.ChannelHandlerContext","io.netty.channel.ChannelPromise"] }, + {"name":"exceptionCaught","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.lang.Throwable"] }, + {"name":"flush","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"read","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"userEventTriggered","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.lang.Object"] }, + {"name":"write","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.lang.Object","io.netty.channel.ChannelPromise"] } + ] +}, +{ + "name":"io.netty.channel.DefaultChannelPipeline$TailContext", + "methods":[ + {"name":"channelActive","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"channelInactive","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"channelRead","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.lang.Object"] }, + {"name":"channelReadComplete","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"channelRegistered","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"channelUnregistered","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"channelWritabilityChanged","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"exceptionCaught","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.lang.Throwable"] }, + {"name":"userEventTriggered","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.lang.Object"] } + ] +}, +{ + "name":"io.netty.channel.SimpleChannelInboundHandler", + "methods":[{"name":"channelRead","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.lang.Object"] }] +}, +{ + "name":"io.netty.channel.pool.SimpleChannelPool$1" +}, +{ + "name":"io.netty.handler.codec.ByteToMessageDecoder", + "methods":[ + {"name":"channelRead","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.lang.Object"] }, + {"name":"userEventTriggered","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.lang.Object"] } + ] +}, +{ + "name":"io.netty.handler.codec.http2.Http2ConnectionHandler", + "methods":[ + {"name":"bind","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.net.SocketAddress","io.netty.channel.ChannelPromise"] }, + {"name":"channelActive","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"channelInactive","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"channelReadComplete","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"channelWritabilityChanged","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"close","parameterTypes":["io.netty.channel.ChannelHandlerContext","io.netty.channel.ChannelPromise"] }, + {"name":"connect","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.net.SocketAddress","java.net.SocketAddress","io.netty.channel.ChannelPromise"] }, + {"name":"deregister","parameterTypes":["io.netty.channel.ChannelHandlerContext","io.netty.channel.ChannelPromise"] }, + {"name":"disconnect","parameterTypes":["io.netty.channel.ChannelHandlerContext","io.netty.channel.ChannelPromise"] }, + {"name":"exceptionCaught","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.lang.Throwable"] }, + {"name":"flush","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"read","parameterTypes":["io.netty.channel.ChannelHandlerContext"] } + ] +}, +{ + "name":"io.netty.handler.codec.http2.HttpToHttp2ConnectionHandler", + "methods":[{"name":"write","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.lang.Object","io.netty.channel.ChannelPromise"] }] +}, +{ + "name":"io.netty.handler.ssl.ApplicationProtocolNegotiationHandler", + "methods":[ + {"name":"exceptionCaught","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.lang.Throwable"] }, + {"name":"userEventTriggered","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.lang.Object"] } + ] +}, +{ + "name":"io.netty.handler.ssl.SslHandler", + "methods":[ + {"name":"bind","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.net.SocketAddress","io.netty.channel.ChannelPromise"] }, + {"name":"channelActive","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"channelInactive","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"channelReadComplete","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"close","parameterTypes":["io.netty.channel.ChannelHandlerContext","io.netty.channel.ChannelPromise"] }, + {"name":"connect","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.net.SocketAddress","java.net.SocketAddress","io.netty.channel.ChannelPromise"] }, + {"name":"deregister","parameterTypes":["io.netty.channel.ChannelHandlerContext","io.netty.channel.ChannelPromise"] }, + {"name":"disconnect","parameterTypes":["io.netty.channel.ChannelHandlerContext","io.netty.channel.ChannelPromise"] }, + {"name":"exceptionCaught","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.lang.Throwable"] }, + {"name":"flush","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"read","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"write","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.lang.Object","io.netty.channel.ChannelPromise"] } + ] +}, +{ + "name":"io.netty.handler.timeout.IdleStateHandler", + "methods":[ + {"name":"channelActive","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"channelInactive","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"channelRead","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.lang.Object"] }, + {"name":"channelReadComplete","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"channelRegistered","parameterTypes":["io.netty.channel.ChannelHandlerContext"] }, + {"name":"write","parameterTypes":["io.netty.channel.ChannelHandlerContext","java.lang.Object","io.netty.channel.ChannelPromise"] } + ] +}, +{ + "name":"io.netty.handler.timeout.ReadTimeoutHandler" +}, +{ + "name":"io.netty.util.ReferenceCountUtil", + "allDeclaredMethods":true +}, +{ + "name":"io.netty.util.internal.shaded.org.jctools.queues.BaseMpscLinkedArrayQueueColdProducerFields", + "fields":[{"name":"producerLimit"}] +}, +{ + "name":"io.netty.util.internal.shaded.org.jctools.queues.BaseMpscLinkedArrayQueueConsumerFields", + "fields":[{"name":"consumerIndex"}] +}, +{ + "name":"io.netty.util.internal.shaded.org.jctools.queues.BaseMpscLinkedArrayQueueProducerFields", + "fields":[{"name":"producerIndex"}] +}, +{ + "name":"io.netty.util.internal.shaded.org.jctools.queues.MpscArrayQueueConsumerIndexField", + "fields":[{"name":"consumerIndex"}] +}, +{ + "name":"io.netty.util.internal.shaded.org.jctools.queues.MpscArrayQueueProducerIndexField", + "fields":[{"name":"producerIndex"}] +}, +{ + "name":"io.netty.util.internal.shaded.org.jctools.queues.MpscArrayQueueProducerLimitField", + "fields":[{"name":"producerLimit"}] +}, +{ + "name":"io.reactivex.Completable" +}, +{ + "name":"io.reactivex.Maybe" +}, +{ + "name":"io.reactivex.Observable" +}, +{ + "name":"io.reactivex.Single" +}, +{ + "name":"java.io.FilePermission" +}, +{ + "name":"java.lang.Object", + "allDeclaredFields":true, + "allDeclaredMethods":true +}, +{ + "name":"java.lang.RuntimePermission" +}, +{ + "name":"java.lang.String" +}, +{ + "name":"java.lang.String[]" +}, +{ + "name":"java.lang.System", + "methods":[{"name":"console","parameterTypes":[] }] +}, +{ + "name":"java.lang.Throwable", + "methods":[{"name":"getSuppressed","parameterTypes":[] }] +}, +{ + "name":"java.lang.management.ManagementFactory", + "methods":[{"name":"getRuntimeMXBean","parameterTypes":[] }] +}, +{ + "name":"java.lang.management.RuntimeMXBean", + "methods":[ + {"name":"getInputArguments","parameterTypes":[] }, + {"name":"getName","parameterTypes":[] } + ] +}, +{ + "name":"java.net.NetPermission" +}, +{ + "name":"java.net.SocketPermission" +}, +{ + "name":"java.net.URLPermission", + "methods":[{"name":"","parameterTypes":["java.lang.String","java.lang.String"] }] +}, +{ + "name":"java.nio.Bits", + "fields":[{"name":"UNALIGNED"}] +}, +{ + "name":"java.nio.Buffer", + "fields":[{"name":"address"}] +}, +{ + "name":"java.nio.ByteBuffer", + "methods":[{"name":"alignedSlice","parameterTypes":["int"] }] +}, +{ + "name":"java.nio.DirectByteBuffer", + "methods":[{"name":"","parameterTypes":["long","int"] }] +}, +{ + "name":"java.nio.file.Path" +}, +{ + "name":"java.nio.file.Paths", + "methods":[{"name":"get","parameterTypes":["java.lang.String","java.lang.String[]"] }] +}, +{ + "name":"java.security.AlgorithmParametersSpi" +}, +{ + "name":"java.security.AllPermission" +}, +{ + "name":"java.security.KeyStoreSpi" +}, +{ + "name":"java.security.MessageDigestSpi" +}, +{ + "name":"java.security.SecureRandomParameters" +}, +{ + "name":"java.security.SecurityPermission" +}, +{ + "name":"java.security.interfaces.ECPrivateKey" +}, +{ + "name":"java.security.interfaces.ECPublicKey" +}, +{ + "name":"java.security.interfaces.RSAPrivateKey" +}, +{ + "name":"java.security.interfaces.RSAPublicKey" +}, +{ + "name":"java.sql.Connection" +}, +{ + "name":"java.sql.Date" +}, +{ + "name":"java.sql.Driver" +}, +{ + "name":"java.sql.DriverManager", + "methods":[ + {"name":"getConnection","parameterTypes":["java.lang.String"] }, + {"name":"getDriver","parameterTypes":["java.lang.String"] } + ] +}, +{ + "name":"java.sql.Time", + "methods":[{"name":"","parameterTypes":["long"] }] +}, +{ + "name":"java.sql.Timestamp", + "methods":[{"name":"valueOf","parameterTypes":["java.lang.String"] }] +}, +{ + "name":"java.time.Duration", + "methods":[{"name":"parse","parameterTypes":["java.lang.CharSequence"] }] +}, +{ + "name":"java.time.Instant", + "methods":[{"name":"parse","parameterTypes":["java.lang.CharSequence"] }] +}, +{ + "name":"java.time.LocalDate", + "methods":[{"name":"parse","parameterTypes":["java.lang.CharSequence"] }] +}, +{ + "name":"java.time.LocalDateTime", + "methods":[{"name":"parse","parameterTypes":["java.lang.CharSequence"] }] +}, +{ + "name":"java.time.LocalTime", + "methods":[{"name":"parse","parameterTypes":["java.lang.CharSequence"] }] +}, +{ + "name":"java.time.MonthDay", + "methods":[{"name":"parse","parameterTypes":["java.lang.CharSequence"] }] +}, +{ + "name":"java.time.OffsetDateTime", + "methods":[{"name":"parse","parameterTypes":["java.lang.CharSequence"] }] +}, +{ + "name":"java.time.OffsetTime", + "methods":[{"name":"parse","parameterTypes":["java.lang.CharSequence"] }] +}, +{ + "name":"java.time.Period", + "methods":[{"name":"parse","parameterTypes":["java.lang.CharSequence"] }] +}, +{ + "name":"java.time.Year", + "methods":[{"name":"parse","parameterTypes":["java.lang.CharSequence"] }] +}, +{ + "name":"java.time.YearMonth", + "methods":[{"name":"parse","parameterTypes":["java.lang.CharSequence"] }] +}, +{ + "name":"java.time.ZoneId", + "methods":[{"name":"of","parameterTypes":["java.lang.String"] }] +}, +{ + "name":"java.time.ZoneOffset", + "methods":[{"name":"of","parameterTypes":["java.lang.String"] }] +}, +{ + "name":"java.time.ZonedDateTime", + "methods":[{"name":"parse","parameterTypes":["java.lang.CharSequence"] }] +}, +{ + "name":"java.util.Date" +}, +{ + "name":"java.util.PropertyPermission" +}, +{ + "name":"javax.crypto.interfaces.DHPrivateKey" +}, +{ + "name":"javax.crypto.interfaces.DHPublicKey" +}, +{ + "name":"javax.management.ObjectName" +}, +{ + "name":"javax.net.ssl.SSLEngine", + "methods":[ + {"name":"getApplicationProtocol","parameterTypes":[] }, + {"name":"getHandshakeApplicationProtocol","parameterTypes":[] }, + {"name":"getHandshakeApplicationProtocolSelector","parameterTypes":[] }, + {"name":"setHandshakeApplicationProtocolSelector","parameterTypes":["java.util.function.BiFunction"] } + ] +}, +{ + "name":"javax.net.ssl.SSLParameters", + "methods":[{"name":"setApplicationProtocols","parameterTypes":["java.lang.String[]"] }] +}, +{ + "name":"javax.security.auth.x500.X500Principal", + "fields":[{"name":"thisX500Name"}], + "methods":[{"name":"","parameterTypes":["sun.security.x509.X500Name"] }] +}, +{ + "name":"jdk.internal.misc.Unsafe", + "methods":[{"name":"getUnsafe","parameterTypes":[] }] +}, +{ + "name":"org.eclipse.jgit.internal.JGitText", + "allPublicFields":true, + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.eclipse.jgit.lib.CoreConfig$AutoCRLF", + "methods":[{"name":"values","parameterTypes":[] }] +}, +{ + "name":"org.eclipse.jgit.lib.CoreConfig$CheckStat", + "methods":[{"name":"values","parameterTypes":[] }] +}, +{ + "name":"org.eclipse.jgit.lib.CoreConfig$EOL", + "methods":[{"name":"values","parameterTypes":[] }] +}, +{ + "name":"org.eclipse.jgit.lib.CoreConfig$HideDotFiles", + "methods":[{"name":"values","parameterTypes":[] }] +}, +{ + "name":"org.eclipse.jgit.lib.CoreConfig$LogRefUpdates", + "methods":[{"name":"values","parameterTypes":[] }] +}, +{ + "name":"org.eclipse.jgit.lib.CoreConfig$SymLinks", + "methods":[{"name":"values","parameterTypes":[] }] +}, +{ + "name":"picocli.CommandLine$AutoHelpMixin", + "allDeclaredFields":true, + "allDeclaredMethods":true +}, +{ + "name":"sun.misc.Unsafe", + "fields":[{"name":"theUnsafe"}], + "methods":[ + {"name":"copyMemory","parameterTypes":["java.lang.Object","long","java.lang.Object","long","long"] }, + {"name":"getAndAddLong","parameterTypes":["java.lang.Object","long","long"] }, + {"name":"getAndSetObject","parameterTypes":["java.lang.Object","long","java.lang.Object"] }, + {"name":"invokeCleaner","parameterTypes":["java.nio.ByteBuffer"] } + ] +}, +{ + "name":"sun.nio.ch.SelectorImpl", + "fields":[ + {"name":"publicSelectedKeys"}, + {"name":"selectedKeys"} + ] +}, +{ + "name":"sun.security.pkcs.SignerInfo[]" +}, +{ + "name":"sun.security.pkcs12.PKCS12KeyStore", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.pkcs12.PKCS12KeyStore$DualFormatPKCS12", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.provider.DSA$SHA224withDSA", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.provider.DSA$SHA256withDSA", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.provider.JavaKeyStore$DualFormatJKS", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.provider.JavaKeyStore$JKS", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.provider.NativePRNG", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.provider.SHA", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.provider.SHA2$SHA224", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.provider.SHA2$SHA256", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.provider.SHA5$SHA384", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.provider.SHA5$SHA512", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.provider.X509Factory", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.provider.certpath.PKIXCertPathValidator", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.rsa.RSAKeyFactory$Legacy", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.rsa.RSAPSSSignature", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.rsa.RSASignature$SHA224withRSA", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.rsa.RSASignature$SHA256withRSA", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.rsa.RSASignature$SHA384withRSA", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.rsa.RSASignature$SHA512withRSA", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.ssl.KeyManagerFactoryImpl$SunX509", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.ssl.SSLContextImpl$DefaultSSLContext", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.ssl.SSLContextImpl$TLSContext", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.ssl.TrustManagerFactoryImpl$PKIXFactory", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.util.ObjectIdentifier" +}, +{ + "name":"sun.security.x509.AuthorityInfoAccessExtension", + "methods":[{"name":"","parameterTypes":["java.lang.Boolean","java.lang.Object"] }] +}, +{ + "name":"sun.security.x509.AuthorityKeyIdentifierExtension", + "methods":[{"name":"","parameterTypes":["java.lang.Boolean","java.lang.Object"] }] +}, +{ + "name":"sun.security.x509.BasicConstraintsExtension", + "methods":[{"name":"","parameterTypes":["java.lang.Boolean","java.lang.Object"] }] +}, +{ + "name":"sun.security.x509.CRLDistributionPointsExtension", + "methods":[{"name":"","parameterTypes":["java.lang.Boolean","java.lang.Object"] }] +}, +{ + "name":"sun.security.x509.CertificateExtensions" +}, +{ + "name":"sun.security.x509.CertificatePoliciesExtension", + "methods":[{"name":"","parameterTypes":["java.lang.Boolean","java.lang.Object"] }] +}, +{ + "name":"sun.security.x509.ExtendedKeyUsageExtension", + "methods":[{"name":"","parameterTypes":["java.lang.Boolean","java.lang.Object"] }] +}, +{ + "name":"sun.security.x509.IssuerAlternativeNameExtension", + "methods":[{"name":"","parameterTypes":["java.lang.Boolean","java.lang.Object"] }] +}, +{ + "name":"sun.security.x509.KeyUsageExtension", + "methods":[{"name":"","parameterTypes":["java.lang.Boolean","java.lang.Object"] }] +}, +{ + "name":"sun.security.x509.NetscapeCertTypeExtension", + "methods":[{"name":"","parameterTypes":["java.lang.Boolean","java.lang.Object"] }] +}, +{ + "name":"sun.security.x509.PrivateKeyUsageExtension", + "methods":[{"name":"","parameterTypes":["java.lang.Boolean","java.lang.Object"] }] +}, +{ + "name":"sun.security.x509.SubjectAlternativeNameExtension", + "methods":[{"name":"","parameterTypes":["java.lang.Boolean","java.lang.Object"] }] +}, +{ + "name":"sun.security.x509.SubjectKeyIdentifierExtension", + "methods":[{"name":"","parameterTypes":["java.lang.Boolean","java.lang.Object"] }] +} +] diff --git a/src/main/resources/META-INF/native-image/resource-config.json b/src/main/resources/META-INF/native-image/resource-config.json new file mode 100644 index 0000000..8326ba3 --- /dev/null +++ b/src/main/resources/META-INF/native-image/resource-config.json @@ -0,0 +1,20 @@ +{ + "resources":{ + "includes":[ + {"pattern":"\\QMETA-INF/services/com.fasterxml.jackson.databind.Module\\E"}, + {"pattern":"\\QMETA-INF/services/io.micronaut.context.env.PropertySourceLoader\\E"}, + {"pattern":"\\QMETA-INF/services/io.micronaut.core.beans.BeanIntrospectionReference\\E"}, + {"pattern":"\\QMETA-INF/services/io.micronaut.core.type.TypeInformationProvider\\E"}, + {"pattern":"\\QMETA-INF/services/io.micronaut.http.HttpRequestFactory\\E"}, + {"pattern":"\\QMETA-INF/services/io.micronaut.inject.BeanConfiguration\\E"}, + {"pattern":"\\QMETA-INF/services/io.micronaut.inject.BeanDefinitionReference\\E"}, + {"pattern":"\\QMETA-INF/services/java.nio.file.spi.FileSystemProvider\\E"}, + {"pattern":"\\QMETA-INF/services/org.eclipse.jgit.transport.SshSessionFactory\\E"}, + {"pattern":"\\QVERSION\\E"}, + {"pattern":"\\Qapplication.yml\\E"}, + {"pattern":"\\Qlogback-full-logs.xml\\E"}, + {"pattern":"\\Qlogback.xml\\E"}, + {"pattern":"\\Qorg/slf4j/impl/StaticLoggerBinder.class\\E"} + ]}, + "bundles":[{"name":"org.eclipse.jgit.internal.JGitText"}] +} diff --git a/src/main/resources/META-INF/native-image/serialization-config.json b/src/main/resources/META-INF/native-image/serialization-config.json new file mode 100644 index 0000000..0d4f101 --- /dev/null +++ b/src/main/resources/META-INF/native-image/serialization-config.json @@ -0,0 +1,2 @@ +[ +] diff --git a/src/main/resources/META-INF/services/org.eclipse.jgit.transport.SshSessionFactory b/src/main/resources/META-INF/services/org.eclipse.jgit.transport.SshSessionFactory new file mode 100644 index 0000000..0433cb6 --- /dev/null +++ b/src/main/resources/META-INF/services/org.eclipse.jgit.transport.SshSessionFactory @@ -0,0 +1 @@ +devex.git.OverrideJschConfigSessionFactory diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..ddbef8e --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,12 @@ +micronaut: + application: + name: devex + ssl: + enabled: true + http: + client: + http-version: 2.0 + read-timeout: 60s + +gitlab: + url: "https://gitlab.com" diff --git a/src/main/resources/logback-full-logs.xml b/src/main/resources/logback-full-logs.xml new file mode 100644 index 0000000..4fa5a9a --- /dev/null +++ b/src/main/resources/logback-full-logs.xml @@ -0,0 +1,14 @@ + + + + true + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..ab4393a --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,14 @@ + + + + true + + %highlight(%-5level) - %msg%n%nopex + + + + + + + + diff --git a/src/test/java/devex/DevexCommandTest.java b/src/test/java/devex/DevexCommandTest.java new file mode 100644 index 0000000..014e8d9 --- /dev/null +++ b/src/test/java/devex/DevexCommandTest.java @@ -0,0 +1,45 @@ +package devex; + +import io.micronaut.context.ApplicationContext; +import io.micronaut.context.env.Environment; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; + +import static org.assertj.core.api.Assertions.assertThat; + +public class DevexCommandTest { + + @Test + public void testVersion() { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + System.setOut(new PrintStream(baos)); + + try (ApplicationContext ctx = ApplicationContext.run(Environment.CLI, Environment.TEST)) { + String[] args = new String[]{"-V"}; + DevexCommand.execute(ctx, args); + + assertThat(baos.toString()).startsWith(new DevexCommand.AppVersionProvider().getVersionText()); + } + } + + @Test + public void testHelp() { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + System.setOut(new PrintStream(baos)); + + try (ApplicationContext ctx = ApplicationContext.run(Environment.CLI, Environment.TEST)) { + String[] args = new String[]{"-h"}; + DevexCommand.execute(ctx, args); + + final String output = baos.toString(); + assertThat(output).contains("Developer experience tools, saving time by automating gruntwork.") + .contains("Description:") + .contains("The devex cli it a collection of tools aimed at automating day to day tasks") + .contains("Options:") + .contains("Commands:") + .contains("Copyright(c) 2021 - Miguel Ferreira - GitHub/GitLab: @miguelaferreira"); + } + } +} diff --git a/src/test/java/devex/GitlabCloneCommandBase.java b/src/test/java/devex/GitlabCloneCommandBase.java new file mode 100644 index 0000000..15592e4 --- /dev/null +++ b/src/test/java/devex/GitlabCloneCommandBase.java @@ -0,0 +1,118 @@ +package devex; + +import io.micronaut.context.ApplicationContext; +import io.micronaut.context.env.Environment; +import org.assertj.core.api.AbstractFileAssert; +import org.assertj.core.api.AbstractStringAssert; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.PrintStream; +import java.nio.file.Path; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +public class GitlabCloneCommandBase extends TestBase { + + public static final String PUBLIC_GROUP_NAME = "gitlab-clone-example"; + public static final String PUBLIC_GROUP_ID = "11961707"; + public static final String PRIVATE_GROUP_NAME = "gitlab-clone-example-private"; + public static final int TEST_OUTPUT_ARRAY_SIZE = 4096000; + public static final Map NO_TOKEN_CONTEXT_PROPERTIES = Map.of("gitlab.token", ""); + public static final String PUBLIC_SUB_GROUP_FULL_PATH = "gitlab-clone-example/sub-group-2/sub-group-3"; + + public ByteArrayOutputStream redirectOutput() { + ByteArrayOutputStream baos = new ByteArrayOutputStream(TEST_OUTPUT_ARRAY_SIZE); + System.setOut(new PrintStream(baos)); + return baos; + } + + public ApplicationContext buildApplicationContext(Map contextProperties) { + return ApplicationContext.run(contextProperties, Environment.CLI, Environment.TEST); + } + + public ApplicationContext buildApplicationContext() { + return ApplicationContext.run(Map.of(), Environment.CLI, Environment.TEST); + } + + public void assertDebugFullLogs(String output, String groupName) { + assertThat(output).contains("] DEBUG devex.DevexCommand - Set all loggers to DEBUG") + .contains(String.format("] INFO devex.GitlabCloneCommand - Cloning group '%s'", groupName)) + .contains(String.format("] DEBUG devex.gitlab.GitlabService - Looking for group named: %s", groupName)) + .contains(String.format("] DEBUG devex.gitlab.GitlabService - Searching for projects in group '%s'", groupName)) + .contains("] INFO devex.GitlabCloneCommand - All done") + .doesNotContain("PRIVATE-TOKEN"); + } + + public void assertTraceFullLogs(String output, String groupName) { + assertThat(output).contains("] TRACE devex.DevexCommand - Set all loggers to TRACE") + .contains(String.format("] INFO devex.GitlabCloneCommand - Cloning group '%s'", groupName)) + .contains(String.format("] DEBUG devex.gitlab.GitlabService - Looking for group named: %s", groupName)) + .contains(String.format("] DEBUG devex.gitlab.GitlabService - Searching for projects in group '%s'", groupName)) + .contains("] TRACE devex.gitlab.GitlabService - Invoking paginated API") + .contains("] INFO devex.GitlabCloneCommand - All done") + .contains("PRIVATE-TOKEN"); + } + + public AbstractStringAssert assertLogsDebug(String output, String group, String groupPath) { + return assertThat(output).contains("Set application loggers to DEBUG") + .contains(String.format("Cloning group '%s'", group)) + .contains(String.format("Searching for projects in group '%s'", groupPath)) + .contains("All done") + .doesNotContain("PRIVATE-TOKEN") + .doesNotContain("devex.GitlabCloneCommand"); + } + + public AbstractStringAssert assertLogsTrace(String output, String groupName) { + return assertThat(output).contains("Set application loggers to TRACE") + .contains(String.format("Cloning group '%s'", groupName)) + .contains(String.format("Looking for group named: %s", groupName)) + .doesNotContain("devex.GitlabCloneCommand"); + } + + public AbstractStringAssert assertLogsTraceWhenGroupFound(AbstractStringAssert testAssert, String groupName) { + return testAssert.contains(String.format("Searching for projects in group '%s'", groupName)) + .contains("Invoking paginated API") + .contains("All done") + .doesNotContain("PRIVATE-TOKEN") + .doesNotContain("devex.GitlabCloneCommand"); + } + + public AbstractStringAssert assertLogsTraceWhenGroupNotFound(AbstractStringAssert testAssert, String groupName) { + return testAssert.contains(String.format("Could not find group '%s': Group not found", groupName)); + } + + public void assertCloneContentsPublicGroup(File cloneDirectory, boolean withSubmodules) { + final AbstractFileAssert abstractFileAssert = assertThat(cloneDirectory); + abstractFileAssert.isDirectoryContaining(String.format("glob:**/%s", PUBLIC_GROUP_NAME)) + .isDirectoryRecursivelyContaining(String.format("glob:**/%s/a-project/README.md", PUBLIC_GROUP_NAME)) + .isDirectoryRecursivelyContaining(String.format("glob:**/%s/sub-group-1/some-project/README.md", PUBLIC_GROUP_NAME)) + .isDirectoryRecursivelyContaining(String.format("glob:**/%s/sub-group-2/sub-group-3/another-project/README.md", PUBLIC_GROUP_NAME)); + + if (withSubmodules) { + abstractFileAssert.isDirectoryRecursivelyContaining(String.format("glob:**/%s/a-project/some-project-sub-module/README.md", PUBLIC_GROUP_NAME)); + } else { + final Path submodulePath = Path.of(cloneDirectory.getAbsolutePath(), PUBLIC_GROUP_NAME, "a-project", "some-project-sub-module"); + assertThat(submodulePath).isEmptyDirectory(); + } + } + + public void assertCloneContentsPublicSubGroup(File cloneDirectory) { + final AbstractFileAssert abstractFileAssert = assertThat(cloneDirectory); + abstractFileAssert.isDirectoryContaining(String.format("glob:**/%s", PUBLIC_GROUP_NAME)) + .isDirectoryRecursivelyContaining(String.format("glob:**/%s/sub-group-2/sub-group-3/another-project/README.md", PUBLIC_GROUP_NAME)); + } + + public void assertCloneContentsPrivateGroup(File cloneDirectory) { + final AbstractFileAssert abstractFileAssert = assertThat(cloneDirectory); + + abstractFileAssert.isDirectoryContaining(String.format("glob:**/%s", PRIVATE_GROUP_NAME)) + .isDirectoryRecursivelyContaining(String.format("glob:**/%s/a-private-project/README.md", PRIVATE_GROUP_NAME)) + .isDirectoryRecursivelyContaining(String.format("glob:**/%s/sub-group-1/another-private-project/README.md", PRIVATE_GROUP_NAME)); + } + + public void assertNotCloned(File cloneDirectory) { + assertThat(cloneDirectory).isEmptyDirectory(); + } +} diff --git a/src/test/java/devex/GitlabCloneCommandFullLogs.java b/src/test/java/devex/GitlabCloneCommandFullLogs.java new file mode 100644 index 0000000..c2a7f43 --- /dev/null +++ b/src/test/java/devex/GitlabCloneCommandFullLogs.java @@ -0,0 +1,40 @@ +package devex; + +import io.micronaut.context.ApplicationContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.io.File; + +public class GitlabCloneCommandFullLogs extends GitlabCloneCommandBase { + + @TempDir + File cloneDirectory; + + @Test + public void run_publicGroup_debug() { + ByteArrayOutputStream baos = redirectOutput(); + + try (ApplicationContext ctx = buildApplicationContext()) { + String[] args = new String[]{"gitlab", "clone", "--debug", PUBLIC_GROUP_NAME, cloneDirectory.toPath().toString()}; + DevexCommand.execute(ctx, args); + + assertDebugFullLogs(baos.toString(), PUBLIC_GROUP_NAME); + assertCloneContentsPublicGroup(cloneDirectory, false); + } + } + + @Test + public void run_privateGroup_trace() { + ByteArrayOutputStream baos = redirectOutput(); + + try (ApplicationContext ctx = buildApplicationContext()) { + String[] args = new String[]{"gitlab", "clone", "--trace", PRIVATE_GROUP_NAME, cloneDirectory.toPath().toString()}; + DevexCommand.execute(ctx, args); + + assertTraceFullLogs(baos.toString(), PRIVATE_GROUP_NAME); + assertCloneContentsPrivateGroup(cloneDirectory); + } + } +} diff --git a/src/test/java/devex/GitlabCloneCommandWithTokenTest.java b/src/test/java/devex/GitlabCloneCommandWithTokenTest.java new file mode 100644 index 0000000..7828f6e --- /dev/null +++ b/src/test/java/devex/GitlabCloneCommandWithTokenTest.java @@ -0,0 +1,75 @@ +package devex; + +import ch.qos.logback.core.joran.spi.JoranException; +import io.micronaut.context.ApplicationContext; +import org.assertj.core.api.AbstractStringAssert; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.PrintStream; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +public class GitlabCloneCommandWithTokenTest extends GitlabCloneCommandBase { + + @TempDir + File cloneDirectory; + private ByteArrayOutputStream baos; + + @BeforeAll + static void beforeAll() throws JoranException { + // Need to do this because configuration is static and loaded once per JVM, + // if the full logs test runs before this one the full log configuration + // remains active. + LoggingConfiguration.loadLogsConfig(); + } + + @Test + public void run_publicGroup_withoutRecursion_verbose() { + ByteArrayOutputStream baos = redirectOutput(); + + try (ApplicationContext ctx = buildApplicationContext()) { + String[] args = new String[]{"gitlab", "clone", "-v", PUBLIC_GROUP_NAME, cloneDirectory.toPath().toString()}; + DevexCommand.execute(ctx, args); + + assertLogsDebug(baos.toString(), PUBLIC_GROUP_NAME, PUBLIC_GROUP_NAME) + .contains(String.format("Looking for group named: %s", PUBLIC_GROUP_NAME)); + assertCloneContentsPublicGroup(cloneDirectory, false); + final Path submodulePath = Path.of(cloneDirectory.getAbsolutePath(), "gitlab-clone-example", "a-project", "some-project-sub-module"); + assertThat(submodulePath).isEmptyDirectory(); + } + } + + @Test + public void run_privateGroup_withoutRecursion_VeryVerbose() { + ByteArrayOutputStream baos = redirectOutput(); + + try (ApplicationContext ctx = buildApplicationContext()) { + String[] args = new String[]{"gitlab", "clone", "-x", PRIVATE_GROUP_NAME, cloneDirectory.toPath().toString()}; + DevexCommand.execute(ctx, args); + + final AbstractStringAssert testAssert = assertLogsTrace(baos.toString(), PRIVATE_GROUP_NAME); + assertLogsTraceWhenGroupFound(testAssert, PRIVATE_GROUP_NAME); + assertCloneContentsPrivateGroup(cloneDirectory); + } + } + + @Test + public void run_publicGroup_withRecursion_veryVerbose() { + ByteArrayOutputStream baos = new ByteArrayOutputStream(TEST_OUTPUT_ARRAY_SIZE); + System.setOut(new PrintStream(baos)); + + try (ApplicationContext ctx = buildApplicationContext()) { + String[] args = new String[]{"gitlab", "clone", "-x", "-r", PUBLIC_GROUP_NAME, cloneDirectory.toPath().toString()}; + DevexCommand.execute(ctx, args); + + final AbstractStringAssert testAssert = assertLogsTrace(baos.toString(), PUBLIC_GROUP_NAME); + assertLogsTraceWhenGroupFound(testAssert, PUBLIC_GROUP_NAME); + assertCloneContentsPublicGroup(cloneDirectory, true); + } + } +} diff --git a/src/test/java/devex/GitlabCloneCommandWithoutTokenTest.java b/src/test/java/devex/GitlabCloneCommandWithoutTokenTest.java new file mode 100644 index 0000000..41c9c53 --- /dev/null +++ b/src/test/java/devex/GitlabCloneCommandWithoutTokenTest.java @@ -0,0 +1,82 @@ +package devex; + +import ch.qos.logback.core.joran.spi.JoranException; +import io.micronaut.context.ApplicationContext; +import org.assertj.core.api.AbstractStringAssert; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.io.File; + +public class GitlabCloneCommandWithoutTokenTest extends GitlabCloneCommandBase { + + @TempDir + File cloneDirectory; + + @BeforeAll + static void beforeAll() throws JoranException { + // Need to do this because configuration is static and loaded once per JVM, + // if the full logs test runs before this one the full log configuration + // remains active. + LoggingConfiguration.loadLogsConfig(); + } + + @Test + public void run_publicGroup_withRecursion_verbose() { + ByteArrayOutputStream baos = redirectOutput(); + + try (ApplicationContext ctx = buildApplicationContext(NO_TOKEN_CONTEXT_PROPERTIES)) { + String[] args = new String[]{"gitlab", "clone", "-v", "-r", PUBLIC_GROUP_NAME, cloneDirectory.toPath().toString()}; + DevexCommand.execute(ctx, args); + + assertLogsDebug(baos.toString(), PUBLIC_GROUP_NAME, PUBLIC_GROUP_NAME) + .contains(String.format("Looking for group named: %s", PUBLIC_GROUP_NAME)); + assertCloneContentsPublicGroup(cloneDirectory, true); + } + } + + @Test + public void run_publicSubGroupByPath_withRecursion_verbose() { + ByteArrayOutputStream baos = redirectOutput(); + + try (ApplicationContext ctx = buildApplicationContext(NO_TOKEN_CONTEXT_PROPERTIES)) { + String[] args = new String[]{"gitlab", "clone", "-v", "-r", "-m", "full_path", PUBLIC_SUB_GROUP_FULL_PATH, cloneDirectory + .toPath().toString()}; + DevexCommand.execute(ctx, args); + + assertLogsDebug(baos.toString(), PUBLIC_SUB_GROUP_FULL_PATH, PUBLIC_SUB_GROUP_FULL_PATH) + .contains(String.format("Looking for group with full path: %s", PUBLIC_SUB_GROUP_FULL_PATH)); + assertCloneContentsPublicSubGroup(cloneDirectory); + } + } + + @Test + public void run_publicGroupById_withRecursion_verbose() { + ByteArrayOutputStream baos = redirectOutput(); + + try (ApplicationContext ctx = buildApplicationContext(NO_TOKEN_CONTEXT_PROPERTIES)) { + String[] args = new String[]{"gitlab", "clone", "-v", "-r", "-m", "id", PUBLIC_GROUP_ID, cloneDirectory.toPath().toString()}; + DevexCommand.execute(ctx, args); + + assertLogsDebug(baos.toString(), PUBLIC_GROUP_ID, PUBLIC_GROUP_NAME) + .contains(String.format("Looking for group identified by: %s", PUBLIC_GROUP_ID)); + assertCloneContentsPublicGroup(cloneDirectory, true); + } + } + + @Test + public void run_privateGroup_withoutRecursion_veryVerbose() { + ByteArrayOutputStream baos = redirectOutput(); + + try (ApplicationContext ctx = buildApplicationContext(NO_TOKEN_CONTEXT_PROPERTIES)) { + String[] args = new String[]{"gitlab", "clone", "-x", PRIVATE_GROUP_NAME, cloneDirectory.toPath().toString()}; + DevexCommand.execute(ctx, args); + + final AbstractStringAssert testAssert = assertLogsTrace(baos.toString(), PRIVATE_GROUP_NAME); + assertLogsTraceWhenGroupNotFound(testAssert, PRIVATE_GROUP_NAME); + assertNotCloned(cloneDirectory); + } + } +} diff --git a/src/test/java/devex/GitlabCommandTest.java b/src/test/java/devex/GitlabCommandTest.java new file mode 100644 index 0000000..4090428 --- /dev/null +++ b/src/test/java/devex/GitlabCommandTest.java @@ -0,0 +1,45 @@ +package devex; + +import io.micronaut.context.ApplicationContext; +import io.micronaut.context.env.Environment; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; + +import static org.assertj.core.api.Assertions.assertThat; + +public class GitlabCommandTest { + + @Test + public void testVersion() { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + System.setOut(new PrintStream(baos)); + + try (ApplicationContext ctx = ApplicationContext.run(Environment.CLI, Environment.TEST)) { + String[] args = new String[]{"gitlab", "-V"}; + DevexCommand.execute(ctx, args); + + assertThat(baos.toString()).startsWith(new DevexCommand.AppVersionProvider().getVersionText()); + } + } + + @Test + public void testHelp() { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + System.setOut(new PrintStream(baos)); + + try (ApplicationContext ctx = ApplicationContext.run(Environment.CLI, Environment.TEST)) { + String[] args = new String[]{"gitlab", "-h"}; + DevexCommand.execute(ctx, args); + + final String output = baos.toString(); + assertThat(output).contains("Developer experience tools, saving time by automating gruntwork.") + .contains("GitLab configuration:") + .contains("The GitLab URL and private token are read from the environment") + .contains("Options:") + .contains("Commands:") + .contains("Copyright(c) 2021 - Miguel Ferreira - GitHub/GitLab: @miguelaferreira"); + } + } +} diff --git a/src/test/java/devex/TestBase.java b/src/test/java/devex/TestBase.java new file mode 100644 index 0000000..20f9542 --- /dev/null +++ b/src/test/java/devex/TestBase.java @@ -0,0 +1,16 @@ +package devex; + +import ch.qos.logback.core.joran.spi.JoranException; +import org.junit.jupiter.api.BeforeAll; + +public class TestBase { + + @BeforeAll + static void beforeAll() throws JoranException { + // Need to do this because configuration is static and loaded once per JVM, + // if the full logs test runs before this one the full log configuration + // remains active. + LoggingConfiguration.loadLogsConfig(); + } + +} diff --git a/src/test/java/devex/git/GitServiceTest.java b/src/test/java/devex/git/GitServiceTest.java new file mode 100644 index 0000000..7479e53 --- /dev/null +++ b/src/test/java/devex/git/GitServiceTest.java @@ -0,0 +1,266 @@ +package devex.git; + +import devex.TestBase; +import devex.gitlab.GitlabProject; +import io.reactivex.Flowable; +import io.vavr.collection.Stream; +import io.vavr.control.Either; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.submodule.SubmoduleStatus; +import org.eclipse.jgit.submodule.SubmoduleStatusType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.jgit.submodule.SubmoduleStatusType.INITIALIZED; +import static org.eclipse.jgit.submodule.SubmoduleStatusType.UNINITIALIZED; + +class GitServiceTest extends TestBase { + + public static final String GITLAB_BOT_USERNAME = "devex-bot"; + + @TempDir + File cloneDirectory; + private String cloneDirectoryPath; + private final String gitlabBotPassword = System.getenv("GITLAB_TOKEN"); + + @BeforeEach + void setUp() { + cloneDirectoryPath = this.cloneDirectory.toPath().toString(); + } + + @Test + void clonePublicRepo_ssh_withSubmodule() throws GitAPIException { + final GitlabProject project = GitlabProject.builder() + .name("a-project") + .sshUrlToRepo("git@gitlab.com:gitlab-clone-example/a-project.git") + .nameWithNamespace("gitlab-clone-example / a-project") + .pathWithNamespace("gitlab-clone-example/a-project") + .build(); + + final Git repo = new GitService().cloneProject(project, cloneDirectoryPath, true); + + assertThat(repo.log().call()).isNotEmpty(); + assertThat(repo.submoduleStatus().call()).containsKey("some-project-sub-module") + .allSatisfy(this::submoduleIsInitialized); + } + + @Test + void clonePublicRepo_http_withSubmodule() throws GitAPIException { + final GitService gitService = new GitService(); + gitService.setCloneProtocol(GitCloneProtocol.HTTPS); + final GitlabProject project = GitlabProject.builder() + .name("a-project") + .httpUrlToRepo("https://gitlab.com/gitlab-clone-example/a-project.git") + .nameWithNamespace("gitlab-clone-example / a-project") + .pathWithNamespace("gitlab-clone-example/a-project") + .build(); + + final Git repo = gitService.cloneProject(project, cloneDirectoryPath, true); + + assertThat(repo.log().call()).isNotEmpty(); + assertThat(repo.submoduleStatus().call()).containsKey("some-project-sub-module") + .allSatisfy(this::submoduleIsInitialized); + } + + @Test + void clonePrivateRepo_ssh_withSubmodule() throws GitAPIException { + final GitlabProject project = GitlabProject.builder() + .name("a-private-project") + .sshUrlToRepo("git@gitlab.com:gitlab-clone-example-private/a-private-project.git") + .nameWithNamespace("gitlab-clone-example / a-private-project") + .pathWithNamespace("gitlab-clone-example/a-private-project") + .build(); + + final Git repo = new GitService().cloneProject(project, cloneDirectoryPath, true); + + assertThat(repo.log().call()).isNotEmpty(); + } + + @Test + void clonePrivateRepo_http_withSubmodule() throws GitAPIException { + final GitService gitService = new GitService(); + gitService.setCloneProtocol(GitCloneProtocol.HTTPS); + gitService.setHttpsUsername(GITLAB_BOT_USERNAME); + gitService.setHttpsPassword(gitlabBotPassword); + final GitlabProject project = GitlabProject.builder() + .name("a-private-project") + .httpUrlToRepo("https://gitlab.com/gitlab-clone-example-private/a-private-project.git") + .nameWithNamespace("gitlab-clone-example / a-private-project") + .pathWithNamespace("gitlab-clone-example/a-private-project") + .build(); + + final Git repo = gitService.cloneProject(project, cloneDirectoryPath, true); + + assertThat(repo.log().call()).isNotEmpty(); + } + + @Test + void clonePublicRepo_ssh_withoutSubmodule() throws GitAPIException { + final GitlabProject project = GitlabProject.builder() + .name("a-project") + .sshUrlToRepo("git@gitlab.com:gitlab-clone-example/a-project.git") + .nameWithNamespace("gitlab-clone-example / a-project") + .pathWithNamespace("gitlab-clone-example/a-project") + .build(); + + final Git repo = new GitService().cloneProject(project, cloneDirectory.toPath().toString(), false); + + assertThat(repo.log().call()).isNotEmpty(); + assertThat(repo.submoduleStatus().call()).containsKey("some-project-sub-module") + .allSatisfy(this::submoduleIsUninitialized); + } + + @Test + void testClonePublicRepositories_ssh_freshClone_withSubmodules() throws GitAPIException { + final GitService gitService = new GitService(); + Flowable projects = Flowable.just( + GitlabProject.builder() + .name("a-project") + .sshUrlToRepo("git@gitlab.com:gitlab-clone-example/a-project.git") + .nameWithNamespace("gitlab-clone-example / a-project") + .pathWithNamespace("gitlab-clone-example/a-project") + .build(), + GitlabProject.builder() + .name("some-project") + .sshUrlToRepo("git@gitlab.com:gitlab-clone-example/sub-group-1/some-project.git") + .nameWithNamespace("gitlab-clone-example / sub-group-1 / some-project") + .pathWithNamespace("gitlab-clone-example/sub-group-1/some-project") + .build(), + GitlabProject.builder() + .name("another-project") + .sshUrlToRepo("git@gitlab.com:gitlab-clone-example/sub-group-2/sub-group-3/another-project.git") + .nameWithNamespace("gitlab-clone-example / sub-group-2 / sub-group-3 / another-project") + .pathWithNamespace("gitlab-clone-example/sub-group-2/sub-group-3/another-project") + .build() + ); + + final Stream> result = flowableToStream(projects.map(project -> gitService.cloneProject(project, cloneDirectoryPath))); + final List gits = result.filter(Either::isRight).map(Either::get).toJavaList(); + + assertThat(gits).hasSize(3); + assertThat(gits.get(0).submoduleStatus().call()).containsKey("some-project-sub-module") + .allSatisfy(this::submoduleIsUninitialized); + } + + @Test + void testCloneOrInitSubmodulesPublicRepos_ssh_existingClone_withSubmodules() throws GitAPIException { + final GitService gitService = new GitService(); + Flowable projects = Flowable.just( + GitlabProject.builder() + .name("a-project") + .sshUrlToRepo("git@gitlab.com:gitlab-clone-example/a-project.git") + .nameWithNamespace("gitlab-clone-example / a-project") + .pathWithNamespace("gitlab-clone-example/a-project") + .build(), + GitlabProject.builder() + .name("some-project") + .sshUrlToRepo("git@gitlab.com:gitlab-clone-example/sub-group-1/some-project.git") + .nameWithNamespace("gitlab-clone-example / sub-group-1 / some-project") + .pathWithNamespace("gitlab-clone-example/sub-group-1/some-project") + .build(), + GitlabProject.builder() + .name("another-project") + .sshUrlToRepo("git@gitlab.com:gitlab-clone-example/sub-group-2/sub-group-3/another-project.git") + .nameWithNamespace("gitlab-clone-example / sub-group-2 / sub-group-3 / another-project") + .pathWithNamespace("gitlab-clone-example/sub-group-2/sub-group-3/another-project") + .build() + ); + // create first clone with only one repo, without submodules + final GitlabProject firstProject = projects.blockingFirst(); + final Git existingClone = gitService.cloneProject(firstProject, cloneDirectoryPath, true); + assertThat(existingClone).isNotNull(); + assertThat(existingClone.submoduleStatus().call()).containsKey("some-project-sub-module") + .allSatisfy(this::submoduleIsInitialized); + + // clone entire group + final Stream> result = flowableToStream(projects.map(project -> gitService.cloneOrInitSubmodulesProject(project, cloneDirectoryPath))); + final List errors = result.filter(Either::isLeft).map(Either::getLeft).toJavaList(); + final List gits = result.filter(Either::isRight).map(Either::get).toJavaList(); + final java.util.stream.Stream> submoduleStatus = gits.stream().map(git -> { + try { + return git.submoduleStatus().call(); + } catch (GitAPIException e) { + e.printStackTrace(); + return Map.of(); + } + }); + + assertThat(errors).isEmpty(); + assertThat(gits).hasSize(3); + assertThat(submoduleStatus).allSatisfy(subModules -> assertThat(subModules).allSatisfy(this::submoduleIsInitialized)); + } + + @Test + void testCloneOrInitSubmodulesPublicRepos_ssh_existingClone_withoutSubmodules() throws GitAPIException { + final GitService gitService = new GitService(); + Flowable projects = Flowable.just( + GitlabProject.builder() + .name("a-project") + .sshUrlToRepo("git@gitlab.com:gitlab-clone-example/a-project.git") + .nameWithNamespace("gitlab-clone-example / a-project") + .pathWithNamespace("gitlab-clone-example/a-project") + .build(), + GitlabProject.builder() + .name("some-project") + .sshUrlToRepo("git@gitlab.com:gitlab-clone-example/sub-group-1/some-project.git") + .nameWithNamespace("gitlab-clone-example / sub-group-1 / some-project") + .pathWithNamespace("gitlab-clone-example/sub-group-1/some-project") + .build(), + GitlabProject.builder() + .name("another-project") + .sshUrlToRepo("git@gitlab.com:gitlab-clone-example/sub-group-2/sub-group-3/another-project.git") + .nameWithNamespace("gitlab-clone-example / sub-group-2 / sub-group-3 / another-project") + .pathWithNamespace("gitlab-clone-example/sub-group-2/sub-group-3/another-project") + .build() + ); + // create first clone with only one repo + final GitlabProject firstProject = projects.blockingFirst(); + final Git existingClone = gitService.cloneProject(firstProject, cloneDirectoryPath, false); + assertThat(existingClone).isNotNull(); + assertThat(existingClone.submoduleStatus().call()).containsKey("some-project-sub-module") + .allSatisfy(this::submoduleIsUninitialized); + + // clone entire group + final Stream> result = flowableToStream(projects.map(project -> gitService.cloneOrInitSubmodulesProject(project, cloneDirectoryPath))); + final List errors = result.filter(Either::isLeft).map(Either::getLeft).toJavaList(); + final List gits = result.filter(Either::isRight).map(Either::get).toJavaList(); + final java.util.stream.Stream> submoduleStatus = gits.stream().map(git -> { + try { + return git.submoduleStatus().call(); + } catch (GitAPIException e) { + e.printStackTrace(); + return Map.of(); + } + }); + + assertThat(errors).isEmpty(); + assertThat(gits).hasSize(3); + assertThat(submoduleStatus).allSatisfy(subModules -> assertThat(subModules).allSatisfy(this::submoduleIsInitialized)); + } + + private Stream flowableToStream(Flowable gits) { + return Stream.ofAll(gits.blockingIterable()); + } + + private void submoduleIsInitialized(String name, SubmoduleStatus status) { + assertSubmoduleStatus(name, status, INITIALIZED); + } + + private void submoduleIsUninitialized(String name, SubmoduleStatus value) { + assertSubmoduleStatus(name, value, UNINITIALIZED); + } + + private void assertSubmoduleStatus(String name, SubmoduleStatus status, SubmoduleStatusType initialized) { + assertThat(status) + .extracting("type") + .as("Check status of submodule " + name) + .isInstanceOfSatisfying(SubmoduleStatusType.class, statusType -> assertThat(statusType).isEqualTo(initialized)); + } +} diff --git a/src/test/java/devex/gitlab/GitlabClientWithTokenTest.java b/src/test/java/devex/gitlab/GitlabClientWithTokenTest.java new file mode 100644 index 0000000..05bffdd --- /dev/null +++ b/src/test/java/devex/gitlab/GitlabClientWithTokenTest.java @@ -0,0 +1,147 @@ +package devex.gitlab; + +import devex.TestBase; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.reactivex.Flowable; +import org.junit.jupiter.api.Test; + +import javax.inject.Inject; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@MicronautTest +class GitlabClientWithTokenTest extends TestBase { + + public static final String PRIVATE_GROUP_ID = "12040044"; + public static final String PUBLIC_GROUP_ID = "11961707"; + public static final String PRIVATE_GROUP_NAME = "gitlab-clone-example-private"; + public static final String PUBLIC_GROUP_NAME = "gitlab-clone-example"; + + @Inject + private GitlabClient client; + + @Test + void searchGroups_privateGroup() { + final Flowable>> groups = client.searchGroups(PRIVATE_GROUP_NAME, true, 10, 1); + + final Iterable>> iterable = groups.blockingIterable(); + assertThat(iterable).hasSize(1); + final HttpResponse> response = iterable.iterator().next(); + assertThat(response.getStatus().getCode()).isEqualTo(HttpStatus.OK.getCode()); + assertThat(response.getBody()).isNotEmpty(); + assertThat(response.getBody().get()).hasSize(2) + .allSatisfy(group -> assertThat(group.getFullPath()).contains(PRIVATE_GROUP_NAME)); + } + + @Test + void getGroup_privateGroup_withoutToken() { + final Optional maybeGroup = client.getGroup(PRIVATE_GROUP_ID); + + assertThat(maybeGroup).isNotEmpty(); + assertThat(maybeGroup.get().getId()).isEqualTo(PRIVATE_GROUP_ID); + assertThat(maybeGroup.get().getName()).isEqualTo(PRIVATE_GROUP_NAME); + } + + @Test + void searchGroups_publicGroup() { + final Flowable>> groups = client.searchGroups(PUBLIC_GROUP_NAME, true, 10, 1); + + final Iterable>> iterable = groups.blockingIterable(); + assertThat(iterable).hasSize(1); + final HttpResponse> response = iterable.iterator().next(); + assertThat(response.getStatus().getCode()).isEqualTo(HttpStatus.OK.getCode()); + assertThat(response.getBody()).isNotEmpty(); + assertThat(response.getBody().get()).hasSize(6) + .allSatisfy(group -> assertThat(group.getFullPath()).contains(PUBLIC_GROUP_NAME)); + } + + @Test + void groupSubGroups_privateGroup() { + final Flowable>> groups = client.groupSubGroups(PRIVATE_GROUP_NAME, true, 10, 1); + + final Iterable>> iterable = groups.blockingIterable(); + assertThat(iterable).hasSize(1); + final HttpResponse> response = iterable.iterator().next(); + assertThat(response.getStatus().getCode()).isEqualTo(HttpStatus.OK.getCode()); + assertThat(response.getBody()).isNotEmpty(); + assertThat(response.getBody().get()).hasSize(1) + .allSatisfy(group -> assertThat(group.getFullPath()).contains(PRIVATE_GROUP_NAME)); + } + + @Test + void groupSubGroups_publicGroup() { + final Flowable>> groups = client.groupSubGroups(PUBLIC_GROUP_ID, true, 10, 1); + + final Iterable>> iterable = groups.blockingIterable(); + assertThat(iterable).hasSize(1); + final HttpResponse> response = iterable.iterator().next(); + assertThat(response.getStatus().getCode()).isEqualTo(HttpStatus.OK.getCode()); + assertThat(response.getBody()).isNotEmpty(); + assertThat(response.getBody().get()).hasSize(2) + .allSatisfy(group -> assertThat(group.getFullPath()).contains(PUBLIC_GROUP_NAME)); + } + + @Test + void groupDescendants_privateGroup() { + final Flowable>> groups = client.groupDescendants(PRIVATE_GROUP_ID, true, 10, 1); + + final Iterable>> iterable = groups.blockingIterable(); + assertThat(iterable).hasSize(1); + final HttpResponse> response = iterable.iterator().next(); + assertThat(response.getStatus().getCode()).isEqualTo(HttpStatus.OK.getCode()); + assertThat(response.getBody()).isNotEmpty(); + assertThat(response.getBody().get()).hasSize(1) + .allSatisfy(group -> assertThat(group.getFullPath()).contains(PRIVATE_GROUP_NAME)); + } + + @Test + void groupDescendants_publicGroup() { + final Flowable>> groups = client.groupDescendants(PUBLIC_GROUP_ID, true, 10, 1); + + final Iterable>> iterable = groups.blockingIterable(); + assertThat(iterable).hasSize(1); + final HttpResponse> response = iterable.iterator().next(); + assertThat(response.getStatus().getCode()).isEqualTo(HttpStatus.OK.getCode()); + assertThat(response.getBody()).isNotEmpty(); + assertThat(response.getBody().get()).hasSize(3) + .allSatisfy(group -> assertThat(group.getFullPath()).contains(PUBLIC_GROUP_NAME)); + } + + @Test + void groupProjects_publicGroup() { + final Flowable>> groups = client.groupProjects(PUBLIC_GROUP_ID, true, 10, 1); + + final Iterable>> iterable = groups.blockingIterable(); + assertThat(iterable).hasSize(1); + final HttpResponse> response = iterable.iterator().next(); + assertThat(response.getStatus().getCode()).isEqualTo(HttpStatus.OK.getCode()); + assertThat(response.getBody()).isNotEmpty(); + assertThat(response.getBody().get()).hasSize(1) + .allSatisfy(project -> assertThat(project.getName()).isEqualTo("a-project")); + } + + @Test + void groupProjects_privateGroup() { + final Flowable>> groups = client.groupProjects(PRIVATE_GROUP_ID, true, 10, 1); + + final Iterable>> iterable = groups.blockingIterable(); + assertThat(iterable).hasSize(1); + final HttpResponse> response = iterable.iterator().next(); + assertThat(response.getStatus().getCode()).isEqualTo(HttpStatus.OK.getCode()); + assertThat(response.getBody()).isNotEmpty(); + assertThat(response.getBody().get()).hasSize(1) + .allSatisfy(project -> assertThat(project.getName()).isEqualTo("a-private-project")); + } + + @Test + void getVersion() { + final GitlabVersion version = client.version(); + + assertThat(version).isNotNull(); + assertThat(version.getVersion()).isNotNull(); + } +} diff --git a/src/test/java/devex/gitlab/GitlabClientWithoutTokenTest.java b/src/test/java/devex/gitlab/GitlabClientWithoutTokenTest.java new file mode 100644 index 0000000..9700959 --- /dev/null +++ b/src/test/java/devex/gitlab/GitlabClientWithoutTokenTest.java @@ -0,0 +1,127 @@ +package devex.gitlab; + +import devex.TestBase; +import io.micronaut.context.annotation.Property; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.client.exceptions.HttpClientException; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.reactivex.Flowable; +import org.junit.jupiter.api.Test; + +import javax.inject.Inject; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SuppressWarnings("ResultOfMethodCallIgnored") +@MicronautTest +@Property(name = "gitlab.token") // clear the property +class GitlabClientWithoutTokenTest extends TestBase { + + public static final String PRIVATE_GROUP_ID = "12040044"; + public static final String PUBLIC_GROUP_ID = "11961707"; + public static final String PRIVATE_GROUP_NAME = "gitlab-clone-example-private"; + public static final String PUBLIC_GROUP_NAME = "gitlab-clone-example"; + + @Inject + private GitlabClient client; + + @Test + void searchGroups_privateGroup_withoutToken() { + final Flowable>> groups = client.searchGroups(PRIVATE_GROUP_NAME, true, 10, 1); + + final Iterable>> iterable = groups.blockingIterable(); + assertThat(iterable).hasSize(1); + final HttpResponse> response = iterable.iterator().next(); + assertThat(response.getStatus().getCode()).isEqualTo(HttpStatus.OK.getCode()); + assertThat(response.getBody()).isNotEmpty() + .contains(List.of()); + } + + @Test + void getGroup_privateGroup_withoutToken() { + final Optional maybeGroup = client.getGroup(PRIVATE_GROUP_ID); + + assertThat(maybeGroup).isEmpty(); + } + + @Test + void searchGroups_publicGroup() { + final Flowable>> groups = client.searchGroups(PUBLIC_GROUP_NAME, true, 10, 1); + + final Iterable>> iterable = groups.blockingIterable(); + assertThat(iterable).hasSize(1); + final HttpResponse> response = iterable.iterator().next(); + assertThat(response.getStatus().getCode()).isEqualTo(HttpStatus.OK.getCode()); + assertThat(response.getBody()).isNotEmpty(); + assertThat(response.getBody().get()).hasSize(4) + .allSatisfy(group -> assertThat(group.getFullPath()).contains(PUBLIC_GROUP_NAME)); + } + + @Test + void getGroup_publicGroup_withoutToken() { + final Optional maybeGroup = client.getGroup(PUBLIC_GROUP_ID); + + assertThat(maybeGroup).isNotEmpty(); + assertThat(maybeGroup.get().getId()).isEqualTo(PUBLIC_GROUP_ID); + assertThat(maybeGroup.get().getName()).isEqualTo(PUBLIC_GROUP_NAME); + } + + @Test + void groupDescendants_privateGroup() { + final Flowable>> groups = client.groupDescendants(PRIVATE_GROUP_ID, true, 10, 1); + + final HttpClientResponseException e = assertThrows(HttpClientResponseException.class, groups::blockingFirst); + + assertThat(e).hasMessage("404 Group Not Found"); + } + + @Test + void groupDescendants_publicGroup() { + final Flowable>> groups = client.groupDescendants(PUBLIC_GROUP_ID, true, 10, 1); + + final Iterable>> iterable = groups.blockingIterable(); + assertThat(iterable).hasSize(1); + final HttpResponse> response = iterable.iterator().next(); + assertThat(response.getStatus().getCode()).isEqualTo(HttpStatus.OK.getCode()); + assertThat(response.getBody()).isNotEmpty(); + assertThat(response.getBody().get()).hasSize(3) + .allSatisfy(group -> assertThat(group.getFullPath()).contains(PUBLIC_GROUP_NAME)); + } + + @Test + void groupProjects_publicGroup() { + final Flowable>> groups = client.groupProjects(PUBLIC_GROUP_ID, true, 10, 1); + + final Iterable>> iterable = groups.blockingIterable(); + assertThat(iterable).hasSize(1); + final HttpResponse> response = iterable.iterator().next(); + assertThat(response.getStatus().getCode()).isEqualTo(HttpStatus.OK.getCode()); + assertThat(response.getBody()).isNotEmpty(); + assertThat(response.getBody().get()).hasSize(1) + .allSatisfy(project -> assertThat(project.getName()).isEqualTo("a-project")); + } + + @Test + void groupProjects_privateGroup() { + final Flowable>> groups = client.groupProjects(PRIVATE_GROUP_ID, true, 10, 1); + + final HttpClientException e = assertThrows(HttpClientException.class, groups::blockingFirst); + + assertThat(e).hasMessage("404 Group Not Found"); + } + + @Test + void getVersion() { + final HttpClientResponseException responseException = assertThrows( + HttpClientResponseException.class, + () -> client.version() + ); + + assertThat(responseException.getStatus().getCode()).isEqualTo(401); + } +} diff --git a/src/test/java/devex/gitlab/GitlabServiceTest.java b/src/test/java/devex/gitlab/GitlabServiceTest.java new file mode 100644 index 0000000..8197d41 --- /dev/null +++ b/src/test/java/devex/gitlab/GitlabServiceTest.java @@ -0,0 +1,70 @@ +package devex.gitlab; + +import devex.TestBase; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.reactivex.Flowable; +import io.vavr.control.Either; +import org.assertj.core.api.Assertions; +import org.assertj.vavr.api.VavrAssertions; +import org.junit.jupiter.api.Test; + +import javax.inject.Inject; + +import static org.assertj.core.api.Assertions.assertThat; + +@MicronautTest +class GitlabServiceTest extends TestBase { + + public static final String GITLAB_GROUP_NAME = "gitlab-clone-example"; + public static final String GITLAB_GROUP_FULL_PATH = "gitlab-clone-example/sub-group-2/sub-group-3"; + public static final String GITLAB_GROUP_ID = "11961707"; + + @Inject + private GitlabService service; + + @Test + void findGroupById() { + final Either maybeGroup = service.findGroupBy(GITLAB_GROUP_ID, GitlabGroupSearchMode.ID); + + VavrAssertions.assertThat(maybeGroup).isRight(); + Assertions.assertThat(maybeGroup.get().getName()).isEqualTo(GITLAB_GROUP_NAME); + } + + @Test + void findGroupByName() { + final Either maybeGroup = service.findGroupBy(GITLAB_GROUP_NAME, GitlabGroupSearchMode.NAME); + + VavrAssertions.assertThat(maybeGroup).isRight(); + Assertions.assertThat(maybeGroup.get().getName()).isEqualTo(GITLAB_GROUP_NAME); + } + + @Test + void findGroupByFullPath() { + final Either maybeGroup = service.findGroupBy(GITLAB_GROUP_FULL_PATH, GitlabGroupSearchMode.FULL_PATH); + + VavrAssertions.assertThat(maybeGroup).isRight(); + Assertions.assertThat(maybeGroup.get().getName()).isEqualTo("sub-group-3"); + } + + @Test + void getDescendantGroups() { + final Flowable descendantGroups = service.getDescendantGroups(GITLAB_GROUP_ID); + + assertThat(descendantGroups.blockingIterable()).hasSize(3); + } + + @Test + void getSubGroupsRecursively() { + final Flowable descendantGroups = service.getSubGroupsRecursively(GITLAB_GROUP_ID); + + assertThat(descendantGroups.blockingIterable()).hasSize(3); + } + + @Test + void getGitlabGroupProjects() { + final GitlabGroup group = service.findGroupBy(GITLAB_GROUP_NAME, GitlabGroupSearchMode.NAME).get(); + final Flowable projects = service.getGitlabGroupProjects(group); + + assertThat(projects.blockingIterable()).hasSize(3); + } +} diff --git a/src/test/java/devex/gitlab/GitlabVersionTest.java b/src/test/java/devex/gitlab/GitlabVersionTest.java new file mode 100644 index 0000000..9a0efd2 --- /dev/null +++ b/src/test/java/devex/gitlab/GitlabVersionTest.java @@ -0,0 +1,132 @@ +package devex.gitlab; + +import devex.TestBase; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class GitlabVersionTest extends TestBase { + + @Test + void versionIsBefore_patchVersion() { + final GitlabVersion version = new GitlabVersion("1.2.3", ""); + + assertThat(version.isBefore("2")) + .as("Check version is before") + .isTrue(); + assertThat(version.isBefore("1.3")) + .as("Check version is before") + .isTrue(); + assertThat(version.isBefore("2.1")) + .as("Check version is before") + .isTrue(); + assertThat(version.isBefore("1.2.4")) + .as("Check version is before") + .isTrue(); + assertThat(version.isBefore("2.1.0")) + .as("Check version is before") + .isTrue(); + } + + @Test + void versionIsNotBefore_patchVersion() { + final GitlabVersion version = new GitlabVersion("1.2.3", ""); + + assertThat(version.isBefore("1")) + .as("Check version is not before") + .isFalse(); + assertThat(version.isBefore("1.2")) + .as("Check version is not before") + .isFalse(); + assertThat(version.isBefore("1.2.0")) + .as("Check version is not before") + .isFalse(); + assertThat(version.isBefore("1.2.2")) + .as("Check version is not before") + .isFalse(); + assertThat(version.isBefore("1.2.3")) + .as("Check version is not before") + .isFalse(); + } + + @Test + void versionIsBefore_minorVersion() { + final GitlabVersion version = new GitlabVersion("1.2", ""); + + assertThat(version.isBefore("2")) + .as("Check version is before") + .isTrue(); + assertThat(version.isBefore("1.2.1")) + .as("Check version is before") + .isTrue(); + assertThat(version.isBefore("1.3")) + .as("Check version is before") + .isTrue(); + assertThat(version.isBefore("2.1")) + .as("Check version is before") + .isTrue(); + assertThat(version.isBefore("2.1.0")) + .as("Check version is before") + .isTrue(); + } + + @Test + void versionIsNotBefore_minorVersion() { + final GitlabVersion version = new GitlabVersion("1.2", ""); + + assertThat(version.isBefore("1")) + .as("Check version is not before") + .isFalse(); + assertThat(version.isBefore("1.2")) + .as("Check version is not before") + .isFalse(); + assertThat(version.isBefore("1.2.0")) + .as("Check version is not before") + .isFalse(); + assertThat(version.isBefore("1.2.0")) + .as("Check version is not before") + .isFalse(); + } + + @Test + void versionIsBefore_majorVersion() { + final GitlabVersion version = new GitlabVersion("1", ""); + + assertThat(version.isBefore("1.1")) + .as("Check version is before") + .isTrue(); + assertThat(version.isBefore("2")) + .as("Check version is before") + .isTrue(); + assertThat(version.isBefore("2.0")) + .as("Check version is before") + .isTrue(); + assertThat(version.isBefore("2.0.0")) + .as("Check version is before") + .isTrue(); + } + + @Test + void versionIsNotBefore_majorVersion() { + final GitlabVersion version = new GitlabVersion("1", ""); + + assertThat(version.isBefore("0")) + .as("Check version is not before") + .isFalse(); + assertThat(version.isBefore("1")) + .as("Check version is not before") + .isFalse(); + assertThat(version.isBefore("1.0")) + .as("Check version is not before") + .isFalse(); + assertThat(version.isBefore("0.1")) + .as("Check version is not before") + .isFalse(); + assertThat(version.isBefore("0.0.1")) + .as("Check version is not before") + .isFalse(); + assertThat(version.isBefore("1.0.0")) + .as("Check version is not before") + .isFalse(); + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 0000000..fdc452f --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,7 @@ +micronaut: + http: + client: + read-timeout: 30s + +gitlab: + url: "https://gitlab.com" diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 0000000..197ced8 --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + true + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + +