├── .editorconfig ├── .gitattributes ├── .github ├── actions │ └── build │ │ └── action.yml ├── renovate.json5 ├── scripts │ ├── replace_string.py │ ├── requirements.txt │ ├── test_replace_string.py │ ├── test_resources │ │ └── api_manual.html │ ├── test_update_api_spec_version.py │ └── update_api_spec_version.py └── workflows │ ├── pr.yml │ ├── publish-javadoc.yml │ ├── publish-library.yml │ ├── submit-dependencies.yml │ ├── test-readme-links.yml │ ├── update-api-spec.yml │ └── update-examples.yml ├── .gitignore ├── LICENSE ├── README.md ├── build-logic ├── build.gradle.kts ├── gradle.properties ├── settings.gradle.kts └── src │ ├── functionalTest │ └── kotlin │ │ └── com │ │ └── gabrielfeo │ │ └── task │ │ └── PostProcessGeneratedApiTest.kt │ └── main │ └── kotlin │ └── com │ └── gabrielfeo │ ├── develocity-api-code-generation.gradle.kts │ ├── kotlin-jvm-library.gradle.kts │ ├── no-op.gradle.kts │ ├── published-kotlin-jvm-library.gradle.kts │ ├── task │ └── PostProcessGeneratedApi.kt │ └── test-suites.gradle.kts ├── build.gradle.kts ├── docs └── AccessKeys.md ├── examples ├── build.gradle.kts ├── example-notebooks │ ├── MostFrequentBuilds.ipynb │ └── requirements.txt ├── example-project │ ├── build.gradle.kts │ └── src │ │ └── main │ │ └── kotlin │ │ └── com │ │ └── gabrielfeo │ │ └── develocity │ │ └── api │ │ └── example │ │ ├── Main.kt │ │ └── analysis │ │ └── MostFrequentBuilds.kt └── example-scripts │ └── example-script.main.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── library ├── .openapi-generator-ignore ├── api │ └── library.api ├── build.gradle.kts └── src │ ├── integrationTest │ └── kotlin │ │ └── com │ │ └── gabrielfeo │ │ └── develocity │ │ └── api │ │ ├── DevelocityApiIntegrationTest.kt │ │ ├── extension │ │ ├── BuildsApiExtensionsIntegrationTest.kt │ │ └── RequestRecorder.kt │ │ └── internal │ │ └── jupyter │ │ └── DevelocityApiJupyterIntegrationTest.kt │ ├── main │ └── kotlin │ │ └── com │ │ └── gabrielfeo │ │ └── develocity │ │ └── api │ │ ├── Config.kt │ │ ├── DevelocityApi.kt │ │ ├── extension │ │ ├── BuildAttributesValueExtensions.kt │ │ ├── BuildsApiExtensions.kt │ │ └── Mapping.kt │ │ └── internal │ │ ├── ApiConstants.kt │ │ ├── Env.kt │ │ ├── LoggerFactory.kt │ │ ├── OkHttpClient.kt │ │ ├── Retrofit.kt │ │ ├── SystemProperties.kt │ │ ├── caching │ │ ├── CacheEnforcingInterceptor.kt │ │ └── CacheHitLoggingInterceptor.kt │ │ └── jupyter │ │ └── DevelocityApiJupyterIntegration.kt │ ├── test │ ├── kotlin │ │ └── com │ │ │ └── gabrielfeo │ │ │ └── develocity │ │ │ └── api │ │ │ ├── CacheConfigTest.kt │ │ │ ├── ConfigTest.kt │ │ │ ├── DevelocityApiExtensionsTest.kt │ │ │ ├── DevelocityApiTest.kt │ │ │ ├── FakeBuildsApi.kt │ │ │ ├── OkHttpClientTest.kt │ │ │ ├── RetrofitTest.kt │ │ │ ├── TestResourceUtils.kt │ │ │ ├── extension │ │ │ ├── BuildsApiExtensionsTest.kt │ │ │ └── MappingTest.kt │ │ │ ├── internal │ │ │ ├── LoggerFactoryTest.kt │ │ │ └── caching │ │ │ │ └── CacheEnforcingInterceptorTest.kt │ │ │ └── model │ │ │ ├── BuildAttributesValueExtensionsTest.kt │ │ │ └── FakeBuild.kt │ └── resources │ │ └── gradle-attributes-response.json │ └── testFixtures │ └── kotlin │ └── com │ └── gabrielfeo │ └── develocity │ └── api │ ├── FakeDevelocityApiScaffold.kt │ └── internal │ ├── FakeEnv.kt │ └── FakeSystemProperties.kt └── settings.gradle.kts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.kt, *.kts] 4 | indent_style = space 5 | indent_size = 4 6 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # 2 | # https://help.github.com/articles/dealing-with-line-endings/ 3 | # 4 | # Linux start script should use lf 5 | /gradlew text eol=lf 6 | 7 | # These are Windows script files and should use crlf 8 | *.bat text eol=crlf 9 | 10 | -------------------------------------------------------------------------------- /.github/actions/build/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Build' 2 | description: 'Run a Gradle build' 3 | inputs: 4 | args: 5 | description: "Gradle args" 6 | required: false 7 | artifact-name: 8 | description: "Artifact name" 9 | required: false 10 | path-to-upload: 11 | description: "Path to upload as artifact" 12 | required: false 13 | dry-run: 14 | description: "Whether to --dry-run" 15 | type: boolean 16 | default: false 17 | runs: 18 | using: "composite" 19 | steps: 20 | - name: Set up Java 21 | uses: actions/setup-java@v4 22 | with: 23 | distribution: zulu 24 | java-version: 21 25 | - name: Set up Gradle 26 | uses: gradle/actions/setup-gradle@v4 27 | with: 28 | validate-wrappers: true 29 | add-job-summary-as-pr-comment: 'on-failure' 30 | - name: Run Gradle 31 | shell: bash 32 | run: | 33 | if [[ "${{ inputs.dry-run }}" == "true" ]]; then 34 | ./gradlew --dry-run ${{ inputs.args }} 35 | else 36 | ./gradlew ${{ inputs.args }} 37 | fi 38 | - name: Upload 39 | if: ${{ inputs.path-to-upload }} 40 | uses: actions/upload-artifact@v4 41 | with: 42 | name: ${{ inputs.artifact-name }} 43 | path: ${{ inputs.path-to-upload }} 44 | if-no-files-found: warn 45 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | ], 6 | "schedule": [ 7 | // Runs once, despite '*' minutes (https://docs.renovatebot.com/configuration-options/#schedule) 8 | "* 0-6 * * 1", 9 | ], 10 | "ignorePaths": [ 11 | "**/test_resources/**", 12 | ], 13 | "dependencyDashboard": true, 14 | "branchConcurrentLimit": 0, 15 | "prConcurrentLimit": 0, 16 | "prHourlyLimit": 0, 17 | "rebaseWhen": "behind-base-branch", 18 | // Remove configDescription from PR body 19 | "prBodyTemplate": "{{{header}}}{{{table}}}{{{warnings}}}{{{notes}}}{{{changelogs}}}{{{controls}}}{{{footer}}}", 20 | "packageRules": [ 21 | // Add a changelog link to Develocity plugin PRs 22 | { 23 | "matchDepNames": ["com.gradle.develocity"], 24 | "prBodyNotes": ["https://docs.gradle.com/develocity/gradle-plugin/current#release_history"], 25 | }, 26 | // Group Kotlin/Jupyter artifact bumps 27 | { 28 | "matchDepNames": [ 29 | "org.jetbrains.kotlinx:kotlin-jupyter-test-kit", 30 | "org.jetbrains.kotlinx:kotlin-jupyter-api", 31 | // Two possible names for the plugin (GAV or plugin ID) 32 | "org.jetbrains.kotlinx:kotlin-jupyter-api-gradle-plugin", 33 | "org.jetbrains.kotlin.jupyter.api", 34 | ], 35 | "groupName": "Kotlin/Jupyter", 36 | "groupSlug": "kotlin-jupyter", 37 | }, 38 | // Group bumps of .github/scripts dependencies (pip) 39 | { 40 | "matchManagers": ["pip_requirements"], 41 | "groupName": ".github/scripts dependencies", 42 | } 43 | ], 44 | } 45 | -------------------------------------------------------------------------------- /.github/scripts/replace_string.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # pylint: disable=missing-function-docstring,missing-module-docstring 3 | 4 | import argparse 5 | from pathlib import Path 6 | import re 7 | import sys 8 | import git 9 | 10 | 11 | def main() -> None: 12 | parser = argparse.ArgumentParser() 13 | parser.add_argument("path", type=Path, help="Path to root directory") 14 | parser.add_argument("old", type=str, help="Old string to be replaced") 15 | parser.add_argument( 16 | "new", type=str, help="New string to replace old string") 17 | args = parser.parse_args() 18 | replace_string(args.path, args.old, args.new) 19 | 20 | 21 | def replace_string(path: Path, old: str, new: str) -> None: 22 | repo = git.Repo(path, search_parent_directories=True) 23 | print(f'Replacing {old} for {new}...') 24 | for file in path.glob('**/*'): 25 | if not _should_replace_in(repo, file): 26 | continue 27 | try: 28 | text = file.read_text() 29 | text = _replace_badge_versions(text, old, new) 30 | text = _replace_non_badge_versions(text, old, new) 31 | file.write_text(text) 32 | print(f'Replaced in file {file}') 33 | except UnicodeError as e: 34 | print(f'Error processing file {file}:', e, file=sys.stderr) 35 | 36 | 37 | def _replace_badge_versions(text: str, old: str, new: str) -> str: 38 | return re.sub( 39 | rf'''https://img\.shields\.io/badge/(.+?)-{_badge_version(old)}-(\w+)''', 40 | rf'''https://img.shields.io/badge/\1-{_badge_version(new)}-\2''', 41 | text 42 | ) 43 | 44 | 45 | def _replace_non_badge_versions(text: str, old: str, new: str) -> str: 46 | return re.sub(rf'(?!https://img\.shields\.io/badge/){old}', new, text) 47 | 48 | 49 | def _badge_version(version: str) -> str: 50 | return version.replace('-', '--') 51 | 52 | 53 | def _should_replace_in(repo, file): 54 | return file.is_file() and not repo.ignored(file) and file.parts[0] != '.git' 55 | 56 | 57 | if __name__ == "__main__": 58 | main() 59 | -------------------------------------------------------------------------------- /.github/scripts/requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2025.1.31 2 | charset-normalizer==3.4.1 3 | gitdb==4.0.12 4 | GitPython==3.1.44 5 | idna==3.10 6 | requests==2.32.3 7 | smmap==5.0.2 8 | urllib3==2.4.0 9 | -------------------------------------------------------------------------------- /.github/scripts/test_replace_string.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # pylint: disable=missing-function-docstring,missing-module-docstring,missing-class-docstring 3 | 4 | from textwrap import dedent 5 | import unittest 6 | from unittest import mock 7 | from pathlib import Path 8 | from tempfile import TemporaryDirectory 9 | from replace_string import replace_string 10 | 11 | class TestReplaceString(unittest.TestCase): 12 | 13 | @mock.patch('git.Repo') 14 | def test_stable_to_stable(self, repo): 15 | self._test_replace_string(repo, [ 16 | ('2024.1.0', '2024.2.0'), 17 | ('2024.1.0', '2024.1.1'), 18 | ('2024.4.2', '2025.1.0') 19 | ]) 20 | 21 | @mock.patch('git.Repo') 22 | def test_stable_to_pre_release(self, repo): 23 | self._test_replace_string(repo, [ 24 | ('2024.1.0', '2024.1.1-alpha01'), 25 | ('2024.1.0', '2024.1.1-beta01'), 26 | ('2024.1.0', '2024.2.0-alpha01'), 27 | ('2024.1.0', '2024.2.0-beta01'), 28 | ('2024.4.2', '2025.1.0-alpha01'), 29 | ]) 30 | 31 | @mock.patch('git.Repo') 32 | def test_pre_release_to_stable(self, repo): 33 | self._test_replace_string(repo, [ 34 | ('2024.1.0-alpha01', '2024.1.0'), 35 | ('2024.1.0-beta01', '2024.1.0'), 36 | ('2024.1.0-rc01', '2024.1.0'), 37 | ]) 38 | 39 | @mock.patch('git.Repo') 40 | def test_pre_release_to_pre_release(self, repo): 41 | self._test_replace_string(repo, [ 42 | ('2024.1.0-alpha01', '2024.1.0-alpha02'), 43 | ('2024.1.0-alpha02', '2024.1.0-beta01'), 44 | ('2024.1.0-beta01', '2024.1.1-rc01'), 45 | ]) 46 | 47 | def _test_replace_string(self, repo, replacements): 48 | repo.return_value.ignored.return_value = False 49 | for old, new in replacements: 50 | with TemporaryDirectory() as temp_dir: 51 | target = Path(temp_dir) 52 | write_test_files(target, old) 53 | replace_string(target, old, new) 54 | for file in target.glob('**/*'): 55 | self._assert_replaced(target / file, old, new) 56 | 57 | def _assert_replaced(self, file, old, new): 58 | content = file.read_text() 59 | self._assert_regular_versions_replaced(content, old, new) 60 | self._assert_badge_versions_replaced(content, old, new) 61 | 62 | def _assert_regular_versions_replaced(self, content, old, new): 63 | self.assertIn(new, content) 64 | self.assertNotIn(old, content) 65 | 66 | def _assert_badge_versions_replaced(self, content, old, new): 67 | if "badge/" not in content: 68 | return 69 | self.assertIn( 70 | f"badge/Maven%20Central-{new.replace('-', '--')}-blue", content) 71 | self.assertNotIn( 72 | f"badge/Maven%20Central-{old.replace('-', '--')}-blue", content) 73 | 74 | 75 | def write_test_files(target_dir, old): 76 | (target_dir / 'README.md').write_text(dedent(f''' 77 | # Title 78 | 79 | [![Maven Central](https://img.shields.io/badge/Maven%20Central-{old.replace('-', '--')}-blue)][14] 80 | 81 | ```kotlin 82 | @file:DependsOn("com.gabrielfeo:develocity-api-kotlin:{old}") 83 | implementation("com.gabrielfeo:develocity-api-kotlin:{old}") 84 | ``` 85 | 86 | ``` 87 | %use develocity-api-kotlin(version={old}) 88 | ``` 89 | 90 | [14]: https://central.sonatype.com/artifact/com.gabrielfeo/develocity-api-kotlin/{old} 91 | ''')) 92 | (target_dir / 'build.gradle.kts').write_text(dedent(f''' 93 | dependencies {'{'} 94 | implementation("com.gabrielfeo:develocity-api-kotlin:{old}") 95 | {'}'} 96 | ''')) 97 | (target_dir / 'notebook.ipynb').write_text(dedent(''' 98 | { 99 | "metadata": {}, 100 | "nbformat": 4, 101 | "nbformat_minor": 2, 102 | "cells": [ 103 | { 104 | "cell_type": "code", 105 | "execution_count": 1, 106 | "source": [ 107 | "%use gradle-enterprise-api-kotlin(version={{VERSION}})" 108 | ], 109 | "outputs": [] 110 | } 111 | ] 112 | } 113 | ''').replace('{{VERSION}}', old)) 114 | 115 | 116 | if __name__ == "__main__": 117 | unittest.main() 118 | -------------------------------------------------------------------------------- /.github/scripts/test_update_api_spec_version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from pathlib import Path 4 | from update_api_spec_version import main 5 | from tempfile import NamedTemporaryFile 6 | import unittest 7 | from unittest import mock 8 | 9 | TEST_RESOURCES = Path(__file__).parent / 'test_resources' 10 | TEST_API_MANUAL_HTML = TEST_RESOURCES / 'api_manual.html' 11 | LATEST_VERSION = '2024.1' # Same as HTML 12 | 13 | 14 | class TestCheckForNewApiSpec(unittest.TestCase): 15 | 16 | @mock.patch('builtins.print') 17 | @mock.patch('requests.get') 18 | def test_main_with_update_available(self, mock_get, _): 19 | mock_get.return_value.status_code = 200 20 | mock_get.return_value.text = TEST_API_MANUAL_HTML.read_text() 21 | with self.properties_file(version='2022.4') as file: 22 | main(properties_file=file.name) 23 | self.assert_properties_version(file, LATEST_VERSION) 24 | 25 | @mock.patch('builtins.print') 26 | @mock.patch('requests.get') 27 | def test_main_without_update_available(self, mock_get, _): 28 | mock_get.return_value.status_code = 200 29 | mock_get.return_value.text = TEST_API_MANUAL_HTML.read_text() 30 | with self.properties_file(version=LATEST_VERSION) as file: 31 | with self.assertRaises(SystemExit): 32 | main(properties_file=file.name) 33 | self.assert_properties_version(file, LATEST_VERSION) 34 | 35 | def assert_properties_version(self, file, version): 36 | with open(file.name) as file: 37 | expected = f"develocity.version={version}\nversion={version}.0\n1=2\n" 38 | self.assertEqual(file.read(), expected) 39 | 40 | def properties_file(self, version): 41 | file = NamedTemporaryFile() 42 | content = f"develocity.version={version}\nversion={version}.0\n1=2\n" 43 | file.write(content.encode()) 44 | file.flush() 45 | return file 46 | 47 | 48 | if __name__ == '__main__': 49 | unittest.main() 50 | -------------------------------------------------------------------------------- /.github/scripts/update_api_spec_version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import requests 4 | import re 5 | import fileinput 6 | import sys 7 | 8 | VERSIONS_URL = "https://docs.gradle.com/enterprise/api-manual/" 9 | LATEST_VERSION_REGEX = r']*href="ref/develocity-([\d.]+)-api\.yaml">Specification' 10 | 11 | 12 | def main(properties_file='gradle.properties'): 13 | current = get_current_api_spec_version(properties_file) 14 | print(f"Current spec version in", properties_file, "is", current, file=sys.stderr) 15 | latest = extract_latest_version() 16 | if current == latest: 17 | exit(1) 18 | update_version(properties_file, latest) 19 | print(latest) 20 | 21 | 22 | def get_current_api_spec_version(properties_file) -> str: 23 | with open(properties_file, mode='r') as file: 24 | for line in file.readlines(): 25 | if '=' not in line: 26 | continue 27 | k, v = line.strip().split('=', maxsplit=2) 28 | if k == 'develocity.version': 29 | return v 30 | 31 | 32 | def extract_latest_version(): 33 | resp = requests.get(VERSIONS_URL) 34 | resp.raise_for_status() 35 | match = re.search(LATEST_VERSION_REGEX, resp.text) 36 | if not match: 37 | raise RuntimeError(f"Failed to get latest version from {VERSIONS_URL} \ 38 | with /{LATEST_VERSION_REGEX}/") 39 | print("First match in HTML of ", VERSIONS_URL, "is", match, file=sys.stderr) 40 | return match.group(1) 41 | 42 | 43 | def update_version(properties_file, new_version): 44 | for line in fileinput.input(properties_file, inplace=True): 45 | if '=' in line: 46 | k, v = line.strip().split('=', maxsplit=2) 47 | # Update target API spec version 48 | if k == 'develocity.version': 49 | line = f"{k}={new_version}\n" 50 | # Update library version 51 | if k == 'version': 52 | line = f"{k}={new_version}.0\n" 53 | sys.stdout.write(line) 54 | 55 | 56 | if __name__ == '__main__': 57 | main() 58 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: 'Check PR' 2 | 3 | on: 4 | pull_request 5 | 6 | defaults: 7 | run: 8 | shell: bash 9 | 10 | jobs: 11 | 12 | kotlin-tests: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | - name: gradle test 18 | uses: ./.github/actions/build 19 | with: 20 | args: >- 21 | test 22 | compileIntegrationTestKotlin 23 | :build-logic:check 24 | :library:apiCheck 25 | 26 | python-tests: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | - run: pip install -r .github/scripts/requirements.txt 32 | - name: 'unittest discover' 33 | run: python3 -m unittest discover -bs .github/scripts 34 | 35 | readme-links-test: 36 | uses: ./.github/workflows/test-readme-links.yml 37 | 38 | dry-run-publish-javadoc: 39 | uses: ./.github/workflows/publish-javadoc.yml 40 | with: 41 | dry_run: true 42 | 43 | dry-run-publish-library: 44 | uses: ./.github/workflows/publish-library.yml 45 | with: 46 | dry_run: true 47 | 48 | dry-run-update-api-spec: 49 | uses: ./.github/workflows/update-api-spec.yml 50 | with: 51 | dry_run: true 52 | -------------------------------------------------------------------------------- /.github/workflows/publish-javadoc.yml: -------------------------------------------------------------------------------- 1 | name: 'Publish javadoc' 2 | 3 | on: 4 | push: 5 | tags: ['*'] 6 | workflow_dispatch: 7 | inputs: 8 | dry_run: 9 | description: 'Dry run' 10 | type: boolean 11 | default: false 12 | workflow_call: 13 | inputs: 14 | dry_run: 15 | description: 'Dry run' 16 | type: boolean 17 | default: false 18 | 19 | defaults: 20 | run: 21 | shell: bash 22 | 23 | jobs: 24 | 25 | build-javadoc: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | - name: Build javadoc 31 | uses: ./.github/actions/build 32 | with: 33 | args: >- 34 | dokkaHtml 35 | '-Pversion=${{ github.ref_name }}' 36 | artifact-name: 'docs' 37 | path-to-upload: "library/build/dokka/html/**/*" 38 | 39 | publish-javadoc: 40 | needs: [build-javadoc] 41 | runs-on: ubuntu-latest 42 | permissions: 43 | contents: write 44 | steps: 45 | - name: Checkout 46 | uses: actions/checkout@v4 47 | with: 48 | ref: gh-pages 49 | - name: Delete current javadoc 50 | run: rm -rf docs 51 | - name: Download new javadoc 52 | uses: actions/download-artifact@v4 53 | with: 54 | path: ./ 55 | - name: Commit 56 | run: | 57 | git config user.name github-actions 58 | git config user.email github-actions@github.com 59 | git add docs 60 | git commit --allow-empty -m "Add ${{ github.ref_name }} javadoc" 61 | - name: Push 62 | run: | 63 | if [[ "${{ inputs.dry_run }}" == 'true' ]]; then 64 | args=' --dry-run' 65 | fi 66 | git push $args 67 | -------------------------------------------------------------------------------- /.github/workflows/publish-library.yml: -------------------------------------------------------------------------------- 1 | name: 'Publish library to Central' 2 | 3 | on: 4 | push: 5 | tags: ['*'] 6 | workflow_dispatch: 7 | inputs: 8 | dry_run: 9 | description: 'Dry run' 10 | type: boolean 11 | default: false 12 | workflow_call: 13 | inputs: 14 | dry_run: 15 | description: 'Dry run' 16 | type: boolean 17 | default: false 18 | 19 | defaults: 20 | run: 21 | shell: bash 22 | 23 | jobs: 24 | 25 | build-and-publish: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | - name: Verify that version property matches tag 31 | if: ${{ inputs.dry_run != true }} 32 | run: grep -qP '^version=${{ github.ref_name }}$' gradle.properties 33 | - name: gradle publish 34 | uses: ./.github/actions/build 35 | with: 36 | dry-run: ${{ inputs.dry_run }} 37 | args: >- 38 | publishDevelocityApiKotlinPublicationToMavenCentralRepository 39 | publishRelocationPublicationToMavenCentralRepository 40 | --rerun-tasks 41 | '-Pmaven.central.username=${{ secrets.MAVEN_CENTRAL_USERNAME }}' 42 | '-Pmaven.central.password=${{ secrets.MAVEN_CENTRAL_PASSWORD }}' 43 | '-Psigning.password=${{ secrets.GPG_PASSWORD }}' 44 | '-Psigning.secretKey=${{ secrets.GPG_SECRET_KEY }}' 45 | artifact-name: 'outputs' 46 | path-to-upload: | 47 | library/build/*-api.yaml 48 | library/build/post-processed-api/**/* 49 | library/build/publications/**/* 50 | library/build/libs/**/* 51 | -------------------------------------------------------------------------------- /.github/workflows/submit-dependencies.yml: -------------------------------------------------------------------------------- 1 | name: 'Submit dependencies' 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_call: 8 | workflow_dispatch: 9 | 10 | defaults: 11 | run: 12 | shell: bash 13 | 14 | jobs: 15 | 16 | submit-dependencies: 17 | runs-on: ubuntu-latest 18 | permissions: # The Dependency Submission API requires write permission 19 | contents: write 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | - name: Submit Gradle dependencies 24 | uses: gradle/actions/dependency-submission@v4 25 | env: 26 | DEPENDENCY_GRAPH_EXCLUDE_CONFIGURATIONS: '.*[Tt]est(Compile|Runtime)Classpath' 27 | -------------------------------------------------------------------------------- /.github/workflows/test-readme-links.yml: -------------------------------------------------------------------------------- 1 | name: 'Test README links' 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - gh-pages 8 | workflow_call: 9 | workflow_dispatch: 10 | 11 | defaults: 12 | run: 13 | shell: bash 14 | 15 | jobs: 16 | 17 | test-readme-links: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | - name: Check README links 23 | uses: gaurav-nelson/github-action-markdown-link-check@v1 24 | with: 25 | folder-path: . 26 | max-depth: 1 27 | -------------------------------------------------------------------------------- /.github/workflows/update-api-spec.yml: -------------------------------------------------------------------------------- 1 | name: 'Update API spec' 2 | 3 | on: 4 | schedule: 5 | - cron: '0 6 * * *' 6 | workflow_dispatch: 7 | inputs: 8 | dry_run: 9 | description: 'Dry run' 10 | type: boolean 11 | default: false 12 | workflow_call: 13 | inputs: 14 | dry_run: 15 | description: 'Dry run' 16 | type: boolean 17 | default: false 18 | 19 | defaults: 20 | run: 21 | shell: bash 22 | 23 | jobs: 24 | 25 | update-api-spec: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: 'Checkout' 29 | uses: actions/checkout@v4 30 | - name: 'Update API spec version' 31 | run: | 32 | if ./.github/scripts/update_api_spec_version.py | tee new.txt; then 33 | echo "NEW_VERSION=$(cat new.txt)" >> $GITHUB_ENV 34 | fi 35 | rm new.txt || true 36 | - name: gradle :library:apiDump 37 | uses: ./.github/actions/build 38 | with: 39 | dry-run: ${{ inputs.dry_run }} 40 | args: :library:apiDump 41 | - name: Check for existing PR 42 | if: ${{ env.NEW_VERSION }} 43 | run: | 44 | set -u 45 | update_branch="feature/api-spec-$NEW_VERSION" 46 | echo "UPDATE_BRANCH=$update_branch" >> $GITHUB_ENV 47 | if git ls-remote --exit-code --heads origin "$update_branch"; then 48 | echo "EXISTING_PR=true" >> $GITHUB_ENV 49 | fi 50 | - name: 'Create PR' 51 | if: ${{ inputs.dry_run != true && env.NEW_VERSION && !env.EXISTING_PR }} 52 | uses: peter-evans/create-pull-request@v7 53 | with: 54 | branch: "${{ env.UPDATE_BRANCH }}" 55 | commit-message: "Bump Develocity API spec version to ${{ env.NEW_VERSION }}" 56 | title: | 57 | Bump Develocity API spec version to ${{ env.NEW_VERSION }} 58 | 59 | Generated from workflow run [${{ github.run_id }}][1]. 60 | 61 | [1]: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} 62 | body: "https://docs.gradle.com/enterprise/api-manual/#release_history" 63 | author: "github-actions " 64 | committer: "github-actions " 65 | add-paths: "gradle.properties" 66 | delete-branch: true 67 | -------------------------------------------------------------------------------- /.github/workflows/update-examples.yml: -------------------------------------------------------------------------------- 1 | name: 'Update examples' 2 | 3 | on: 4 | push: 5 | tags: [ '*' ] 6 | workflow_dispatch: 7 | inputs: 8 | ref: 9 | description: 'Branch or tag' 10 | required: true 11 | 12 | defaults: 13 | run: 14 | shell: bash 15 | 16 | jobs: 17 | 18 | update-examples: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: 'Checkout' 22 | uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | - run: pip install -r .github/scripts/requirements.txt 26 | - name: 'Get versions' 27 | run: | 28 | new="${{ inputs.ref || github.ref_name }}" 29 | echo "Deleting '$new' in case it's alpha to stable bump." \ 30 | "Git v:refname doesn't handle Maven version qualifiers correctly." 31 | git tag -d "$new" 32 | old="$(git tag --sort=-v:refname | head -n 1)" 33 | echo "OLD_VERSION=$old" | tee -a $GITHUB_ENV 34 | echo "NEW_VERSION=$new" | tee -a $GITHUB_ENV 35 | - name: 'Update version in all files' 36 | run: ./.github/scripts/replace_string.py ./ "$OLD_VERSION" "$NEW_VERSION" 37 | - name: 'Create PR' 38 | uses: peter-evans/create-pull-request@v7 39 | with: 40 | base: 'main' 41 | branch: "replace-${{ env.OLD_VERSION }}-${{ env.NEW_VERSION }}" 42 | title: "Bump examples and badges to ${{ env.NEW_VERSION }}" 43 | author: "github-actions " 44 | committer: "github-actions " 45 | body: "Bump the version in examples and badges from ${{ env.OLD_VERSION }} to ${{ env.NEW_VERSION }}." 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | .kotlin 3 | out 4 | !*/src/**/out 5 | 6 | gradle-user-home 7 | profile-out* 8 | 9 | build 10 | !.github/actions/build 11 | !*/src/**/build 12 | !*/*/src/**/build 13 | .ipynb_checkpoints 14 | 15 | .venv 16 | __pycache__ 17 | 18 | **/.log 19 | .DS_Store 20 | 21 | .idea 22 | local.properties 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Gabriel Feo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Develocity API Kotlin 2 | 3 | [![Maven Central](https://img.shields.io/badge/Maven%20Central-2024.3.0-blue)][14] 4 | [![Javadoc](https://img.shields.io/badge/Javadoc-2024.3.0-orange)][7] 5 | 6 | (formerly `gradle-enterprise-api-kotlin`) 7 | 8 | A Kotlin library to access the [Develocity API][1], easy to use from: 9 | 10 | - [Jupyter notebooks with the Kotlin kernel][29] 11 | - [Kotlin scripts (`kts`)][27] 12 | - [Kotlin projects][28] 13 | 14 | ```kotlin 15 | val api = DevelocityApi.newInstance() 16 | api.buildsApi.getBuildsFlow(fromInstant = 0, query = "buildStartTime<-1d").forEach { 17 | println(it) 18 | } 19 | ``` 20 | 21 | ## How is this different from the [official samples][33]? 22 | 23 | The official samples are an excellent demo of the API inside a full-fledged Java project. 24 | Among other things, you might not want to maintain an OpenAPI code generation setup. 25 | Even if you do, you'll find it generates less-than-ideal or even failing code depending on your `openapi-generator` configuration. 26 | This library [fixes][34] those issues in generated code, implements [paging][24], [caching][13] and [env-based configuration][8] for you, while providing a JAR that's ready-to-use from any project, script or notebook. 27 | 28 | ## Setup 29 | 30 | Set up environment variables and use the library from any notebook, script or project: 31 | 32 | - [`DEVELOCITY_API_URL`][16]: the URL of your Develocity instance 33 | - [`DEVELOCITY_API_TOKEN`][17]: an [access key][31] for the Develocity instance 34 | - [`DEVELOCITY_API_CACHE_ENABLED`][12] (optional, off by default): enables caching for some 35 | requests (see [caveats][13]) 36 | 37 | ### Setup snippets 38 | 39 |
40 | Add to a Jupyter notebook 41 | 42 | ``` 43 | %useLatestDescriptors 44 | %use develocity-api-kotlin(version=2024.3.0) 45 | ``` 46 | 47 |
48 | 49 |
50 | Add to a Kotlin script 51 | 52 | ```kotlin 53 | @file:DependsOn("com.gabrielfeo:develocity-api-kotlin:2024.3.0") 54 | ``` 55 | 56 |
57 | 58 |
59 | Add to a Kotlin project 60 | 61 | ```kotlin 62 | dependencies { 63 | implementation("com.gabrielfeo:develocity-api-kotlin:2024.3.0") 64 | } 65 | ``` 66 | 67 |
68 | 69 | ## Usage 70 | 71 | The [`DevelocityApi`][9] interface represents the Develocity REST API. It contains 72 | all the APIs exactly as listed in the [REST API Manual][5]: 73 | 74 | ```kotlin 75 | interface DevelocityApi { 76 | val buildsApi: BuildsApi 77 | val testsApi: TestsApi 78 | val buildCacheApi: BuildCacheApi 79 | val projectsApi: ProjectsApi 80 | val metaApi: MetaApi 81 | val testDistributionApi: TestDistributionApi 82 | val authApi: AuthApi 83 | // ... 84 | } 85 | ``` 86 | 87 | For example, [`BuildsApi`][20] contains all endpoints under `/api/builds/`: 88 | 89 | - [`BuildsApi.getBuilds`][21]: `GET /api/builds` 90 | - [`BuildsApi.getGradleAttributes`][22]: `GET /api/builds/{id}/gradle-attributes` 91 | - ... 92 | 93 | ### Calling the APIs 94 | 95 | API methods are generated as suspend functions. 96 | For most cases like scripts and notebooks, simply use [runBlocking][30]: 97 | 98 | ```kotlin 99 | runBlocking { 100 | val builds: List = api.buildsApi.getBuilds(fromInstant = 0, query = "...") 101 | } 102 | ``` 103 | 104 | ### Caching 105 | 106 | HTTP caching is available, which can speed up queries significantly, but is 107 | off by default. Enable by simply setting [`DEVELOCITY_API_CACHE_ENABLED`][12] to `true`. See 108 | [`CacheConfig`][13] for caveats. 109 | 110 | ### Extensions 111 | 112 | Explore the library's convenience extensions: 113 | [`com.gabrielfeo.develocity.api.extension`][25]. 114 | 115 | By default, the API's most common endpoint, `/api/builds`, is paginated. The library provides a 116 | [`getBuildsFlow`][24] extension to handle paging under-the-hood and yield all builds as you collect 117 | them: 118 | 119 | ```kotlin 120 | val builds: Flow = api.buildsApi.getBuildsFlow(fromInstant = 0, query = "...") 121 | builds.collect { 122 | // ... 123 | } 124 | ``` 125 | 126 | ### Shutdown 127 | 128 | By default, the library keeps some of its resources (like threads) alive until idle, in 129 | case they're needed again. This is an optimization of [OkHttp][4]. If you're working on a notebook 130 | or have a long-living program that fetches builds continuosly, no shutdown is needed. 131 | 132 | ```kotlin 133 | val api = DevelocityApi.newInstance() 134 | while (true) { 135 | delay(2.minutes) 136 | processNewBuilds(api.buildsApi.getBuildsFlow(query = "...")) 137 | // Don't worry about shutdown 138 | } 139 | ``` 140 | 141 | In other cases (i.e. fetching some builds and exiting), you might want to call 142 | [`DevelocityApi.shutdown()`][11] so that the program exits immediately: 143 | 144 | ```kotlin 145 | val api = DevelocityApi.newInstance() 146 | printMetrics(api.buildsApi.getBuildsFlow(query = "...")) 147 | // Call shutdown if you expect the program to exit now 148 | api.shutdown() 149 | ``` 150 | 151 | ### Working samples 152 | 153 | - [Jupyter notebooks with the Kotlin kernel][29] 154 | - [Kotlin scripts (`kts`)][27] 155 | - [Kotlin projects][28] 156 | 157 | ## Documentation 158 | 159 | [![Javadoc](https://img.shields.io/badge/javadoc-latest-orange)][7] 160 | 161 | The javadoc of API interfaces and models, such as [`BuildsApi`][18] and [`GradleAttributes`][19], 162 | matches the [REST API Manual][5] exactly. Both these classes and Gradle's own manual are generated 163 | from the same OpenAPI spec. 164 | 165 | ## Optional setup 166 | 167 | Creating a custom [`Config`][8] allows you to change library settings via code instead of 168 | environment variables. It also lets you share resources between the library's `OkHttpClient` and 169 | your own. For example: 170 | 171 | ```kotlin 172 | val config = Config( 173 | apiUrl = "https://ge.mycompany.com/api/", 174 | apiToken = { vault.getGeApiToken() }, 175 | clientBuilder = existingClient.newBuilder(), 176 | ) 177 | val api = DevelocityApi.newInstance(config) 178 | api.buildsApi.getBuilds(fromInstant = yesterdayMilli) 179 | ``` 180 | 181 | See the [`Config`][8] documentation for more. 182 | 183 | ## More info 184 | 185 | - Use JDK 8 or 14+ to run, if you want to avoid the ["illegal reflective access" warning about 186 | Retrofit][3] 187 | - All classes live in these packages. If you need to make small edits to scripts where there's 188 | no auto-complete, wildcard imports can be used (in notebooks, they're added automatically): 189 | 190 | ```kotlin 191 | import com.gabrielfeo.develocity.api.* 192 | import com.gabrielfeo.develocity.api.model.* 193 | import com.gabrielfeo.develocity.api.model.extension.* 194 | ``` 195 | 196 | ## Issues and contributions 197 | 198 | If you run into any problems, please open an issue to make it visible to maintainers and other users. 199 | Contributions are always welcome, although it's recommended to open an issue first. 200 | For general discussions or questions, feel free to reach out to maintainers on the [Gradle Community Slack][35]. 201 | 202 | [1]: https://docs.gradle.com/enterprise/api-manual/ 203 | [2]: https://square.github.io/retrofit/ 204 | [3]: https://github.com/square/retrofit/issues/3448 205 | [4]: https://github.com/square/retrofit/issues/3144#issuecomment-508300518 206 | [5]: https://docs.gradle.com/enterprise/api-manual/ref/2024.1.html 207 | [6]: https://github.com/OpenAPITools/openapi-generator/blob/master/modules/openapi-generator-gradle-plugin/README.adoc 208 | [7]: https://gabrielfeo.github.io/develocity-api-kotlin/ 209 | [8]: https://gabrielfeo.github.io/develocity-api-kotlin/library/com.gabrielfeo.develocity.api/-config/index.html 210 | [9]: https://gabrielfeo.github.io/develocity-api-kotlin/library/com.gabrielfeo.develocity.api/-develocity-api/ 211 | [11]: https://gabrielfeo.github.io/develocity-api-kotlin/library/com.gabrielfeo.develocity.api/-develocity-api/shutdown.html 212 | [12]: https://gabrielfeo.github.io/develocity-api-kotlin/library/com.gabrielfeo.develocity.api/-config/-cache-config/cache-enabled.html 213 | [13]: https://gabrielfeo.github.io/develocity-api-kotlin/library/com.gabrielfeo.develocity.api/-config/-cache-config/index.html 214 | [14]: https://central.sonatype.com/artifact/com.gabrielfeo/develocity-api-kotlin/2024.3.0 215 | [16]: https://gabrielfeo.github.io/develocity-api-kotlin/library/com.gabrielfeo.develocity.api/-config/api-url.html 216 | [17]: https://gabrielfeo.github.io/develocity-api-kotlin/library/com.gabrielfeo.develocity.api/-config/api-token.html 217 | [18]: https://gabrielfeo.github.io/develocity-api-kotlin/library/com.gabrielfeo.develocity.api/-builds-api/index.html 218 | [19]: https://gabrielfeo.github.io/develocity-api-kotlin/library/com.gabrielfeo.develocity.api.model/-gradle-attributes/index.html 219 | [20]: https://gabrielfeo.github.io/develocity-api-kotlin/library/com.gabrielfeo.develocity.api/-builds-api/index.html 220 | [21]: https://gabrielfeo.github.io/develocity-api-kotlin/library/com.gabrielfeo.develocity.api/-builds-api/get-builds.html 221 | [22]: https://gabrielfeo.github.io/develocity-api-kotlin/library/com.gabrielfeo.develocity.api/-builds-api/get-gradle-attributes.html 222 | [23]: https://gabrielfeo.github.io/develocity-api-kotlin/library/com.gabrielfeo.develocity.api/-develocity-api/-default-instance/index.html 223 | [24]: https://gabrielfeo.github.io/develocity-api-kotlin/library/com.gabrielfeo.develocity.api.extension/get-builds-flow.html 224 | [25]: https://gabrielfeo.github.io/develocity-api-kotlin/library/com.gabrielfeo.develocity.api.extension/index.html 225 | [26]: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-flow/ 226 | [27]: ./examples/example-scripts/example-script.main.kts 227 | [28]: ./examples/example-project 228 | [29]: https://nbviewer.org/github/gabrielfeo/develocity-api-kotlin/blob/main/examples/example-notebooks/MostFrequentBuilds.ipynb 229 | [30]: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/run-blocking.html 230 | [31]: ./docs/AccessKeys.md 231 | [32]: ./examples 232 | [33]: https://github.com/gradle/develocity-api-samples 233 | [34]: https://github.com/gabrielfeo/develocity-api-kotlin/blob/main/build-logic/src/functionalTest/kotlin/com/gabrielfeo/task/PostProcessGeneratedApiTest.kt#L21 234 | [35]: https://community.gradle.org/#community-channels 235 | -------------------------------------------------------------------------------- /build-logic/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | plugins { 4 | `kotlin-dsl` 5 | } 6 | 7 | java { 8 | toolchain { 9 | languageVersion = JavaLanguageVersion.of(11) 10 | vendor = JvmVendorSpec.AZUL 11 | } 12 | } 13 | 14 | testing { 15 | suites { 16 | register("functionalTest") { 17 | useJUnitJupiter() 18 | } 19 | } 20 | } 21 | 22 | gradlePlugin { 23 | testSourceSets(sourceSets["functionalTest"]) 24 | } 25 | 26 | tasks.named("check") { 27 | dependsOn(tasks.withType()) 28 | } 29 | 30 | dependencies { 31 | implementation(libs.kotlin.plugin) 32 | implementation(libs.kotlin.binary.compatibility.validator.plugin) 33 | implementation(libs.dokka.plugin) 34 | implementation(libs.openapi.generator.plugin) 35 | "functionalTestImplementation"(project) 36 | } 37 | -------------------------------------------------------------------------------- /build-logic/gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.daemon.jvmargs=-Xmx1g 2 | -------------------------------------------------------------------------------- /build-logic/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | plugins { 4 | id("org.gradle.toolchains.foojay-resolver-convention") version("0.10.0") 5 | } 6 | 7 | dependencyResolutionManagement { 8 | repositories { 9 | mavenCentral() 10 | gradlePluginPortal() 11 | } 12 | versionCatalogs { 13 | create("libs") { 14 | from(files("../gradle/libs.versions.toml")) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /build-logic/src/functionalTest/kotlin/com/gabrielfeo/task/PostProcessGeneratedApiTest.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.task 2 | 3 | import org.gradle.testkit.runner.GradleRunner 4 | import org.junit.jupiter.api.Assertions.assertEquals 5 | import org.junit.jupiter.api.BeforeEach 6 | import org.junit.jupiter.api.Test 7 | import java.io.File 8 | 9 | class PostProcessGeneratedApiTest { 10 | 11 | // Prefer over TempDir to inspect files after tests when troubleshooting 12 | private val tempDir: File = File("./build/test-workdir/") 13 | 14 | @BeforeEach 15 | fun setup() { 16 | tempDir.deleteRecursively() 17 | tempDir.mkdirs() 18 | } 19 | 20 | /** 21 | * - Fixes missing model imports by replacing all with a wildcard (OpenAPITools/openapi-generator#14871) 22 | * - Replaces return types of Response for X, for more idiomatic usage with coroutines 23 | * - Adds @JvmSuppressWildcards to avoid square/retrofit#3275 24 | */ 25 | @Test 26 | fun apiInterfacePostProcessing() = testPostProcessing( 27 | inputPath = "src/main/kotlin/com/gabrielfeo/develocity/api/BuildsApi.kt", 28 | inputContent = """ 29 | package com.gabrielfeo.develocity.api 30 | 31 | import com.gabrielfeo.develocity.api.internal.infrastructure.CollectionFormats.* 32 | import retrofit2.http.* 33 | import retrofit2.Response 34 | import okhttp3.RequestBody 35 | import com.squareup.moshi.Json 36 | 37 | import com.gabrielfeo.develocity.api.model.ApiProblem 38 | import com.gabrielfeo.develocity.api.model.Build 39 | import com.gabrielfeo.develocity.api.model.BuildModelQuery 40 | import com.gabrielfeo.develocity.api.model.BuildQuery 41 | import com.gabrielfeo.develocity.api.model.BuildsQuery 42 | import com.gabrielfeo.develocity.api.model.GradleAttributes 43 | import com.gabrielfeo.develocity.api.model.GradleBuildCachePerformance 44 | import com.gabrielfeo.develocity.api.model.GradleNetworkActivity 45 | import com.gabrielfeo.develocity.api.model.GradleProject 46 | import com.gabrielfeo.develocity.api.model.MavenAttributes 47 | import com.gabrielfeo.develocity.api.model.MavenBuildCachePerformance 48 | import com.gabrielfeo.develocity.api.model.MavenDependencyResolution 49 | import com.gabrielfeo.develocity.api.model.MavenModule 50 | 51 | import com.gabrielfeo.develocity.api.model.* 52 | 53 | interface BuildsApi { 54 | /** 55 | * Get the common attributes of a Build Scan. 56 | * The contained attributes are build tool agnostic. 57 | * Responses: 58 | * - 200: The common attributes of a Build Scan. 59 | * - 400: The request cannot be fulfilled due to a problem. 60 | * - 404: The referenced resource either does not exist or the permissions to know about it are missing. 61 | * - 500: The server encountered an unexpected error. 62 | * - 503: The server is not ready to handle the request. 63 | * 64 | * @param id The Build Scan ID. 65 | * @param models The list of build models to return in the response for each build. If not provided, no models are returned. (optional) 66 | * @param availabilityWaitTimeoutSecs The time in seconds the server should wait for ingestion before returning a wait timeout response. (optional) 67 | * @return [Build] 68 | */ 69 | @GET("api/builds/{id}") 70 | suspend fun getBuild(@Path("id") id: kotlin.String, @Query("models") models: kotlin.collections.List? = null, @Query("availabilityWaitTimeoutSecs") availabilityWaitTimeoutSecs: kotlin.Int? = null): Response 71 | """.trimIndent(), 72 | outputPath = "src/main/kotlin/com/gabrielfeo/develocity/api/BuildsApi.kt", 73 | outputContent = """ 74 | package com.gabrielfeo.develocity.api 75 | 76 | import com.gabrielfeo.develocity.api.internal.infrastructure.CollectionFormats.* 77 | import retrofit2.http.* 78 | import retrofit2.Response 79 | import okhttp3.RequestBody 80 | import com.squareup.moshi.Json 81 | 82 | import com.gabrielfeo.develocity.api.model.ApiProblem 83 | import com.gabrielfeo.develocity.api.model.Build 84 | import com.gabrielfeo.develocity.api.model.BuildModelQuery 85 | import com.gabrielfeo.develocity.api.model.BuildQuery 86 | import com.gabrielfeo.develocity.api.model.BuildsQuery 87 | import com.gabrielfeo.develocity.api.model.GradleAttributes 88 | import com.gabrielfeo.develocity.api.model.GradleBuildCachePerformance 89 | import com.gabrielfeo.develocity.api.model.GradleNetworkActivity 90 | import com.gabrielfeo.develocity.api.model.GradleProject 91 | import com.gabrielfeo.develocity.api.model.MavenAttributes 92 | import com.gabrielfeo.develocity.api.model.MavenBuildCachePerformance 93 | import com.gabrielfeo.develocity.api.model.MavenDependencyResolution 94 | import com.gabrielfeo.develocity.api.model.MavenModule 95 | 96 | import com.gabrielfeo.develocity.api.model.* 97 | 98 | @JvmSuppressWildcards 99 | interface BuildsApi { 100 | /** 101 | * Get the common attributes of a Build Scan. 102 | * The contained attributes are build tool agnostic. 103 | * Responses: 104 | * - 200: The common attributes of a Build Scan. 105 | * - 400: The request cannot be fulfilled due to a problem. 106 | * - 404: The referenced resource either does not exist or the permissions to know about it are missing. 107 | * - 500: The server encountered an unexpected error. 108 | * - 503: The server is not ready to handle the request. 109 | * 110 | * @param id The Build Scan ID. 111 | * @param models The list of build models to return in the response for each build. If not provided, no models are returned. (optional) 112 | * @param availabilityWaitTimeoutSecs The time in seconds the server should wait for ingestion before returning a wait timeout response. (optional) 113 | * @return [Build] 114 | */ 115 | @GET("api/builds/{id}") 116 | suspend fun getBuild(@Path("id") id: kotlin.String, @Query("models") models: kotlin.collections.List? = null, @Query("availabilityWaitTimeoutSecs") availabilityWaitTimeoutSecs: kotlin.Int? = null): Build 117 | """.trimIndent(), 118 | ) 119 | 120 | /** 121 | * - Fixes enum case names: gradleMinusAttributes -> gradleAttributes 122 | */ 123 | @Test 124 | fun buildModelNameEnumPostProcessing() = testPostProcessing( 125 | inputPath = "src/main/kotlin/com/gabrielfeo/develocity/api/model/BuildModelName.kt", 126 | inputContent = """ 127 | @JsonClass(generateAdapter = false) 128 | enum class BuildModelName(val value: kotlin.String) { 129 | 130 | @Json(name = "gradle-attributes") 131 | gradleMinusAttributes("gradle-attributes"), 132 | 133 | @Json(name = "gradle-build-cache-performance") 134 | gradleMinusBuildMinusCacheMinusPerformance("gradle-build-cache-performance"), 135 | """.trimIndent(), 136 | outputPath = "src/main/kotlin/com/gabrielfeo/develocity/api/model/BuildModelName.kt", 137 | outputContent = """ 138 | @JsonClass(generateAdapter = false) 139 | enum class BuildModelName(val value: kotlin.String) { 140 | 141 | @Json(name = "gradle-attributes") 142 | gradleAttributes("gradle-attributes"), 143 | 144 | @Json(name = "gradle-build-cache-performance") 145 | gradleBuildCachePerformance("gradle-build-cache-performance"), 146 | """.trimIndent(), 147 | ) 148 | 149 | /** 150 | * Fixes enum case names: hIT -> hit (gabrielfeo/develocity-api-kotlin#282). 151 | * 152 | * Occurs when API spec enum name is uppercase and generator enumPropertyNaming is camelCase. 153 | */ 154 | @Test 155 | fun gradleConfigurationCacheResultOutcomeEnumPostProcessing() = testPostProcessing( 156 | inputPath = "src/main/kotlin/com/gabrielfeo/develocity/api/model/GradleConfigurationCacheResult.kt", 157 | inputContent = """ 158 | /** 159 | * The outcome of the configuration cache operation: * `HIT` - There was a configuration cache hit. * `MISS` - There was a configuration cache miss. * `FAILED` - There was a configuration cache related failure. 160 | * 161 | * Values: hIT,mISS,fAILED 162 | */ 163 | @JsonClass(generateAdapter = false) 164 | enum class Outcome(val value: kotlin.String) { 165 | @Json(name = "HIT") hIT("HIT"), 166 | @Json(name = "MISS") mISS("MISS"), 167 | @Json(name = "FAILED") fAILED("FAILED"); 168 | } 169 | """.trimIndent(), 170 | outputPath = "src/main/kotlin/com/gabrielfeo/develocity/api/model/GradleConfigurationCacheResult.kt", 171 | outputContent = """ 172 | /** 173 | * The outcome of the configuration cache operation: * `HIT` - There was a configuration cache hit. * `MISS` - There was a configuration cache miss. * `FAILED` - There was a configuration cache related failure. 174 | * 175 | * Values: hit,miss,failed 176 | */ 177 | @JsonClass(generateAdapter = false) 178 | enum class Outcome(val value: kotlin.String) { 179 | @Json(name = "HIT") hit("HIT"), 180 | @Json(name = "MISS") miss("MISS"), 181 | @Json(name = "FAILED") failed("FAILED"); 182 | } 183 | """.trimIndent(), 184 | ) 185 | 186 | private fun testPostProcessing( 187 | inputPath: String, 188 | inputContent: String, 189 | outputPath: String, 190 | outputContent: String, 191 | ) { 192 | val inputDir = File(tempDir, "input").also { it.mkdirs() } 193 | val outputDir = File(tempDir, "output").also { it.mkdirs() } 194 | File(inputDir, inputPath).apply { 195 | parentFile.mkdirs() 196 | writeText(inputContent) 197 | } 198 | val projectDir = writeTestProject(inputDir, outputDir) 199 | runBuild(projectDir, listOf("postProcessGeneratedApi", "--stacktrace")) 200 | assertEquals(outputContent, File(outputDir, outputPath).readText()) 201 | } 202 | 203 | @Suppress("SameParameterValue") 204 | private fun runBuild(projectDir: File, args: List) { 205 | GradleRunner.create() 206 | .withProjectDir(projectDir) 207 | .withPluginClasspath() 208 | .withArguments(args) 209 | .forwardOutput() 210 | .build() 211 | } 212 | 213 | private fun writeTestProject(inputDir: File, outputDir: File): File { 214 | val projectDir = File(tempDir, "project").also { it.mkdirs() } 215 | File(projectDir, "settings.gradle").writeText("") 216 | File(projectDir, "build.gradle").writeText( 217 | // language=groovy 218 | """ 219 | import com.gabrielfeo.task.PostProcessGeneratedApi 220 | 221 | plugins { 222 | id("com.gabrielfeo.no-op") 223 | } 224 | 225 | tasks.register("postProcessGeneratedApi", PostProcessGeneratedApi) { 226 | originalFiles = new File("${inputDir.absolutePath}") 227 | modelsPackage = "com.gabrielfeo.develocity.api.model" 228 | postProcessedFiles = new File("${outputDir.absolutePath}") 229 | } 230 | """.trimIndent() 231 | ) 232 | return projectDir 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /build-logic/src/main/kotlin/com/gabrielfeo/develocity-api-code-generation.gradle.kts: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo 2 | 3 | import com.gabrielfeo.task.PostProcessGeneratedApi 4 | import org.gradle.kotlin.dsl.* 5 | 6 | plugins { 7 | id("com.gabrielfeo.kotlin-jvm-library") 8 | id("org.openapi.generator") 9 | } 10 | 11 | val localSpecPath = providers.gradleProperty("localSpecPath") 12 | val remoteSpecUrl = providers.gradleProperty("remoteSpecUrl").orElse( 13 | providers.gradleProperty("develocity.version").map { geVersion -> 14 | val majorVersion = geVersion.substringBefore('.').toInt() 15 | val specName = when { 16 | majorVersion <= 2023 -> "gradle-enterprise-$geVersion-api.yaml" 17 | else -> "develocity-$geVersion-api.yaml" 18 | } 19 | "https://docs.gradle.com/enterprise/api-manual/ref/$specName" 20 | } 21 | ) 22 | 23 | val downloadApiSpec by tasks.registering { 24 | onlyIf { !localSpecPath.isPresent() } 25 | val spec = resources.text.fromUri(remoteSpecUrl) 26 | val specName = remoteSpecUrl.map { it.substringAfterLast('/') } 27 | val outFile = project.layout.buildDirectory.file(specName) 28 | inputs.property("Spec URL", remoteSpecUrl) 29 | outputs.file(outFile) 30 | doLast { 31 | logger.info("Downloaded API spec from ${remoteSpecUrl.get()}") 32 | spec.asFile().renameTo(outFile.get().asFile) 33 | } 34 | } 35 | 36 | openApiGenerate { 37 | generatorName = "kotlin" 38 | val spec = when { 39 | localSpecPath.isPresent() -> localSpecPath.map { rootProject.file(it).absolutePath } 40 | else -> downloadApiSpec.map { it.outputs.files.first().absolutePath } 41 | } 42 | inputSpec = spec 43 | val generateDir = project.layout.buildDirectory.dir("generated-api") 44 | .map { it.asFile.absolutePath } 45 | outputDir = generateDir 46 | val ignoreFile = project.layout.projectDirectory.file(".openapi-generator-ignore") 47 | ignoreFileOverride = ignoreFile.asFile.absolutePath 48 | apiPackage = "com.gabrielfeo.develocity.api" 49 | modelPackage = "com.gabrielfeo.develocity.api.model" 50 | packageName = "com.gabrielfeo.develocity.api.internal" 51 | invokerPackage = "com.gabrielfeo.develocity.api.internal" 52 | additionalProperties.put("library", "jvm-retrofit2") 53 | additionalProperties.put("useCoroutines", true) 54 | additionalProperties.put("enumPropertyNaming", "camelCase") 55 | cleanupOutput = true 56 | } 57 | 58 | val postProcessGeneratedApi by tasks.registering(PostProcessGeneratedApi::class) { 59 | val generatedSrc = tasks.openApiGenerate 60 | .flatMap { it.outputDir } 61 | .map { File(it) } 62 | originalFiles.convention(project.layout.dir(generatedSrc)) 63 | postProcessedFiles.convention(project.layout.buildDirectory.dir("post-processed-api")) 64 | modelsPackage.convention(tasks.openApiGenerate.flatMap { it.modelPackage }) 65 | } 66 | 67 | sourceSets { 68 | main { 69 | java { 70 | srcDir(postProcessGeneratedApi) 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /build-logic/src/main/kotlin/com/gabrielfeo/kotlin-jvm-library.gradle.kts: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo 2 | 3 | plugins { 4 | id("org.jetbrains.kotlin.jvm") 5 | `java-library` 6 | } 7 | 8 | java { 9 | toolchain { 10 | languageVersion = JavaLanguageVersion.of(11) 11 | vendor = JvmVendorSpec.AZUL 12 | } 13 | consistentResolution { 14 | useRuntimeClasspathVersions() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /build-logic/src/main/kotlin/com/gabrielfeo/no-op.gradle.kts: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo 2 | 3 | // Plugin is only used to test the tasks logic. Applying it adds the task classes to the classpath. -------------------------------------------------------------------------------- /build-logic/src/main/kotlin/com/gabrielfeo/published-kotlin-jvm-library.gradle.kts: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo 2 | 3 | import org.jetbrains.dokka.DokkaConfiguration.Visibility.PUBLIC 4 | import org.jetbrains.dokka.gradle.DokkaTask 5 | import java.net.URL 6 | 7 | plugins { 8 | id("com.gabrielfeo.kotlin-jvm-library") 9 | `java-library` 10 | `maven-publish` 11 | signing 12 | id("org.jetbrains.kotlinx.binary-compatibility-validator") 13 | id("org.jetbrains.dokka") 14 | } 15 | 16 | java { 17 | withSourcesJar() 18 | withJavadocJar() 19 | } 20 | 21 | val kotlinSourceRoot = file("src/main/kotlin") 22 | tasks.withType().configureEach { 23 | dokkaSourceSets.all { 24 | sourceRoot(kotlinSourceRoot) 25 | sourceLink { 26 | localDirectory = kotlinSourceRoot 27 | remoteUrl = providers.gradleProperty("repo.url") 28 | .map { URL("$it/blob/$version/${kotlinSourceRoot.relativeTo(rootDir)}") } 29 | remoteLineSuffix = "#L" 30 | } 31 | jdkVersion = java.toolchain.languageVersion.map { it.asInt() } 32 | suppressGeneratedFiles = false 33 | documentedVisibilities = setOf(PUBLIC) 34 | perPackageOption { 35 | matchingRegex = """.*\.internal.*""" 36 | suppress = true 37 | } 38 | externalDocumentationLink("https://kotlinlang.org/api/kotlinx.coroutines/") 39 | externalDocumentationLink("https://square.github.io/okhttp/5.x/okhttp/") 40 | externalDocumentationLink("https://square.github.io/retrofit/2.x/retrofit/") 41 | externalDocumentationLink("https://square.github.io/moshi/1.x/moshi/") 42 | externalDocumentationLink("https://square.github.io/moshi/1.x/moshi-kotlin/") 43 | } 44 | } 45 | 46 | tasks.named("javadocJar") { 47 | from(tasks.dokkaHtml) 48 | } 49 | 50 | publishing { 51 | repositories { 52 | maven { 53 | name = "mavenCentral" 54 | val releasesRepoUrl = uri("https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/") 55 | val snapshotsRepoUrl = uri("https://s01.oss.sonatype.org/content/repositories/snapshots/") 56 | val isSnapshot = version.toString().endsWith("SNAPSHOT") 57 | url = if (isSnapshot) snapshotsRepoUrl else releasesRepoUrl 58 | authentication { 59 | register("basic") 60 | } 61 | credentials { 62 | username = project.properties["maven.central.username"] as String? 63 | password = project.properties["maven.central.password"] as String? 64 | } 65 | } 66 | } 67 | } 68 | 69 | fun isCI() = System.getenv("CI").toBoolean() 70 | 71 | signing { 72 | val signedPublications = publishing.publications.matching { 73 | !it.name.contains("unsigned", ignoreCase = true) 74 | } 75 | sign(signedPublications) 76 | if (isCI()) { 77 | useInMemoryPgpKeys( 78 | project.properties["signing.secretKey"] as String?, 79 | project.properties["signing.password"] as String?, 80 | ) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /build-logic/src/main/kotlin/com/gabrielfeo/task/PostProcessGeneratedApi.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.task 2 | 3 | import org.gradle.api.DefaultTask 4 | import org.gradle.api.file.DirectoryProperty 5 | import org.gradle.api.file.FileSystemOperations 6 | import org.gradle.api.provider.Property 7 | import org.gradle.api.tasks.* 8 | import org.gradle.kotlin.dsl.withGroovyBuilder 9 | import java.io.File 10 | import javax.inject.Inject 11 | 12 | @CacheableTask 13 | abstract class PostProcessGeneratedApi @Inject constructor( 14 | private val fsOperations: FileSystemOperations, 15 | ) : DefaultTask() { 16 | 17 | @get:InputDirectory 18 | @get:PathSensitive(PathSensitivity.RELATIVE) 19 | abstract val originalFiles: DirectoryProperty 20 | 21 | @get:Input 22 | abstract val modelsPackage: Property 23 | 24 | @get:OutputDirectory 25 | abstract val postProcessedFiles: DirectoryProperty 26 | 27 | @TaskAction 28 | fun doWork() { 29 | postProcessedFiles.get().asFile.deleteRecursively() 30 | fsOperations.copy { 31 | from(originalFiles) 32 | into(postProcessedFiles) 33 | } 34 | postProcess( 35 | srcDir = postProcessedFiles.get().dir("src/main/kotlin").asFile, 36 | modelsPackage = modelsPackage.get() 37 | ) 38 | } 39 | 40 | private fun postProcess(srcDir: File, modelsPackage: String) { 41 | // Replace Response with X in every method return type of DevelocityApi.kt 42 | replaceAll( 43 | match = ": Response<(.*?)>$", 44 | replace = """: \1""", 45 | dir = srcDir, 46 | includes = "com/gabrielfeo/develocity/api/*Api.kt", 47 | ) 48 | // Add @JvmSuppressWildcards to avoid square/retrofit#3275 49 | replaceAll( 50 | match = "interface", 51 | replace = "@JvmSuppressWildcards\ninterface", 52 | dir = srcDir, 53 | includes = "com/gabrielfeo/develocity/api/*Api.kt", 54 | ) 55 | // Fix mapping of BuildModelName: gradle-attributes -> gradleAttributes 56 | replaceAll( 57 | match = "Minus", 58 | replace = "", 59 | dir = srcDir, 60 | includes = "com/gabrielfeo/develocity/api/model/BuildModelName.kt", 61 | ) 62 | // Fix mapping of GradleConfigurationCacheResult.Outcome: hIT -> hit 63 | val file = "com/gabrielfeo/develocity/api/model/GradleConfigurationCacheResult.kt" 64 | replaceAll("hIT", "hit", dir = srcDir, includes = file) 65 | replaceAll("mISS", "miss", dir = srcDir, includes = file) 66 | replaceAll("fAILED", "failed", dir = srcDir, includes = file) 67 | } 68 | 69 | private fun replaceAll( 70 | match: String, 71 | replace: String, 72 | dir: File, 73 | includes: String, 74 | ) { 75 | ant.withGroovyBuilder { 76 | "replaceregexp"( 77 | "match" to match, 78 | "replace" to replace, 79 | "flags" to "mg", 80 | ) { 81 | "fileset"( 82 | "dir" to dir, 83 | "includes" to includes, 84 | ) 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /build-logic/src/main/kotlin/com/gabrielfeo/test-suites.gradle.kts: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo 2 | 3 | plugins { 4 | id("org.jetbrains.kotlin.jvm") 5 | `java-test-fixtures` 6 | } 7 | 8 | testing { 9 | suites { 10 | // 'test' is registered by default 11 | register("integrationTest") 12 | withType().configureEach { 13 | useKotlinTest() 14 | } 15 | } 16 | } 17 | 18 | tasks.named("check") { 19 | dependsOn("integrationTest") 20 | } 21 | 22 | kotlin { 23 | target { 24 | val main by compilations.getting 25 | val integrationTest by compilations.getting 26 | val test by compilations.getting 27 | val testFixtures by compilations.getting 28 | test.associateWith(main) 29 | test.associateWith(testFixtures) 30 | integrationTest.associateWith(main) 31 | integrationTest.associateWith(testFixtures) 32 | testFixtures.associateWith(main) 33 | } 34 | } 35 | 36 | // TODO Unapply test-fixtures and delete the source set, since we're not publishing it? 37 | components.named("java") { 38 | withVariantsFromConfiguration(configurations["testFixturesApiElements"]) { skip() } 39 | withVariantsFromConfiguration(configurations["testFixturesRuntimeElements"]) { skip() } 40 | } 41 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.gabrielfeo.kotlin-jvm-library") apply false 3 | } 4 | 5 | tasks.register("check") { 6 | dependsOn(gradle.includedBuilds.map { it.task(":check") }) 7 | } 8 | -------------------------------------------------------------------------------- /docs/AccessKeys.md: -------------------------------------------------------------------------------- 1 | # Access key / API token 2 | 3 | [All API requests require authentication][1]. Provide a valid access key of your Develocity instance 4 | as the `DEVELOCITY_API_TOKEN` environment variable. 5 | 6 | ## How to get an access key 7 | 8 | 1. Sign in to Develocity (with a user that has “Export build data” permission) 9 | 2. Go to "My settings" from the user menu in the top right-hand corner of the page 10 | 3. Go to "Access keys" from the sidebar 11 | 4. Click "Generate" on the right-hand side 12 | 5. Set key as the `DEVELOCITY_API_TOKEN` environment variable when using the library 13 | 14 | ## Migrating from macOS keychain support 15 | 16 | This library used to support storing the key in the macOS keychain as `gradle-enterprise-api-kotlin`. 17 | This feature was deprecated in 2023.4.0, then removed in 2024.1.1. You may use the method of your choice 18 | (secret managers, password manager CLIs, etc.) to store and retrieve the key to an environment. 19 | 20 | If you used the key from keychain and need a drop-in replacement: 21 | 22 | ``` 23 | # Create an alias in your shell to fetch the key from keychain 24 | echo 'alias dat="security find-generic-password -w -a "$LOGNAME" -s gradle-enterprise-api-kotlin"' >> ~/.zshrc 25 | 26 | # Retrieve it to the environment variable before running the program 27 | DEVELOCITY_API_TOKEN="$(dat)" ./my-script.main.kts 28 | DEVELOCITY_API_TOKEN="$(dat)" jupyter lab 29 | DEVELOCITY_API_TOKEN="$(dat)" idea my-project 30 | ``` 31 | 32 | [1]: https://docs.gradle.com/enterprise/api-manual/#access_control 33 | -------------------------------------------------------------------------------- /examples/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("HasPlatformType") 2 | 3 | plugins { 4 | base 5 | } 6 | 7 | // Cross-configure so we don't pollute the example buildscript 8 | project("example-project") { 9 | apply(plugin = "com.gabrielfeo.kotlin-jvm-library") 10 | configurations.configureEach { 11 | resolutionStrategy.dependencySubstitution { 12 | substitute(module("com.gabrielfeo:develocity-api-kotlin")) 13 | .using(project(":library")) 14 | } 15 | } 16 | } 17 | 18 | val exampleTestTasks = ArrayList>() 19 | 20 | exampleTestTasks += tasks.register("runExampleScript") { 21 | group = "Application" 22 | description = "Runs the './example-scripts/example-script.main.kts' script" 23 | commandLine("kotlinc", "-script", file("./example-scripts/example-script.main.kts")) 24 | environment("JAVA_OPTS", "-Xmx1g") 25 | } 26 | 27 | exampleTestTasks += tasks.register("runExampleProject") { 28 | group = "Application" 29 | description = "Runs examples/example-project" 30 | dependsOn(":examples:example-project:run") 31 | } 32 | 33 | val notebooksDir = file("example-notebooks") 34 | val notebooks = fileTree(notebooksDir) { include("*.ipynb") } 35 | val venvDir = project.layout.buildDirectory.asFile.map { File(it, "venv") } 36 | 37 | val createPythonVenv by tasks.registering(Exec::class) { 38 | val requirements = File(notebooksDir, "requirements.txt") 39 | val venv = venvDir.get() 40 | commandLine( 41 | "bash", "-c", 42 | "python3 -m venv --upgrade-deps $venv " 43 | + "&& source $venv/bin/activate " 44 | + "&& pip install --upgrade pip" 45 | + "&& pip install -r $requirements" 46 | ) 47 | } 48 | 49 | exampleTestTasks += notebooks.map { notebook -> 50 | val buildDir = project.layout.buildDirectory.asFile.get() 51 | tasks.register("run${notebook.nameWithoutExtension}Notebook") { 52 | group = "Application" 53 | description = "Runs the '${notebook.name}' notebook with 'jupyter nbconvert --execute'" 54 | val venv = venvDir.get() 55 | dependsOn(createPythonVenv) 56 | commandLine( 57 | "bash", "-c", 58 | "source $venv/bin/activate " 59 | + "&& jupyter nbconvert --execute --to ipynb --output-dir='$buildDir' '$notebook'" 60 | ) 61 | } 62 | } 63 | 64 | val runAll = tasks.register("runAll") { 65 | group = "Application" 66 | description = "Runs everything in 'examples' directory" 67 | dependsOn(exampleTestTasks) 68 | } 69 | 70 | val parallelExamples: String? by project 71 | if (!parallelExamples.toBoolean()) { 72 | exampleTestTasks.windowed(size = 2, step = 1).forEach { (a, b) -> 73 | b.configure { mustRunAfter(a) } 74 | } 75 | } 76 | 77 | tasks.named("check") { 78 | dependsOn(runAll) 79 | } 80 | -------------------------------------------------------------------------------- /examples/example-notebooks/requirements.txt: -------------------------------------------------------------------------------- 1 | jupyterlab==4.4.0 2 | kotlin-jupyter-kernel==0.12.0.322 3 | -------------------------------------------------------------------------------- /examples/example-project/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("org.jetbrains.kotlin.jvm") 3 | application 4 | } 5 | 6 | application { 7 | mainClass = "com.gabrielfeo.develocity.api.example.MainKt" 8 | } 9 | 10 | dependencies { 11 | implementation("com.gabrielfeo:develocity-api-kotlin:2024.3.0") 12 | } 13 | -------------------------------------------------------------------------------- /examples/example-project/src/main/kotlin/com/gabrielfeo/develocity/api/example/Main.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api.example 2 | 3 | import com.gabrielfeo.develocity.api.Config 4 | import com.gabrielfeo.develocity.api.DevelocityApi 5 | import com.gabrielfeo.develocity.api.example.analysis.mostFrequentBuilds 6 | import okhttp3.OkHttpClient 7 | 8 | /* 9 | * Example main that runs all API analysis at once. In projects, you can share an 10 | * OkHttpClient.Builder between DevelocityApi and your own project classes, in order to 11 | * save resources. 12 | */ 13 | 14 | val clientBuilder = OkHttpClient.Builder() 15 | 16 | suspend fun main() { 17 | val newConfig = Config( 18 | clientBuilder = clientBuilder, 19 | ) 20 | val develocityApi = DevelocityApi.newInstance(newConfig) 21 | runAllAnalysis(develocityApi) 22 | develocityApi.shutdown() 23 | } 24 | 25 | private suspend fun runAllAnalysis(develocityApi: DevelocityApi) { 26 | mostFrequentBuilds(api = develocityApi.buildsApi) 27 | } 28 | -------------------------------------------------------------------------------- /examples/example-project/src/main/kotlin/com/gabrielfeo/develocity/api/example/analysis/MostFrequentBuilds.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api.example.analysis 2 | 3 | import com.gabrielfeo.develocity.api.* 4 | import com.gabrielfeo.develocity.api.extension.* 5 | import com.gabrielfeo.develocity.api.model.* 6 | import kotlinx.coroutines.flow.* 7 | import java.util.LinkedList 8 | 9 | /** 10 | * See what builds are most commonly invoked by developers, e.g. 'clean assemble', 11 | * 'test' or 'check'. You can set up the URL and a token for your Gradle 12 | * Enterprise instance and run this notebook as-is for your own project. This is a 13 | * simple example of something you can do with the API. It could bring insights, 14 | * for example: 15 | * 16 | * - "Our developers frequently clean together with assemble. We should ask them why, 17 | * because they shouldn't have to. Just an old habit from Maven or are they working 18 | * around a build issue we don't know about?" 19 | * 20 | * - "Some are doing check builds locally, which we set up to trigger our notably slow 21 | * legacy tests. We should suggest they run test instead, leaving check for CI to run." 22 | */ 23 | suspend fun mostFrequentBuilds( 24 | api: BuildsApi, 25 | startTime: String = "-7d", 26 | ) { 27 | // Fetch builds from the API 28 | val builds: List = api.getBuildsFlow( 29 | fromInstant = 0, 30 | query = """buildStartTime>$startTime""", 31 | models = listOf(BuildModelName.gradleAttributes), 32 | ).map { 33 | it.models!!.gradleAttributes!!.model!! 34 | }.toList(LinkedList()) 35 | 36 | // Process builds and count how many times each was invoked 37 | check(builds.isNotEmpty()) { "No builds found. Adjust query and try again." } 38 | val buildCounts = builds.groupBy { build -> 39 | val tasks = build.requestedTasks.joinToString(" ").trim(':') 40 | tasks.ifBlank { "IDE sync" } 41 | }.mapValues { (_, builds) -> 42 | builds.size 43 | }.entries.sortedByDescending { (_, count) -> 44 | count 45 | } 46 | 47 | // Print the top 5 as a pretty table 48 | val table = buildCounts.take(5).joinToString("\n") { (tasks, count) -> 49 | "${tasks.padEnd(100)} | $count" 50 | } 51 | println( 52 | """ 53 | |--------------------- 54 | |Most frequent builds: 55 | | 56 | |$table 57 | """.trimMargin() 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /examples/example-scripts/example-script.main.kts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env kotlinc -script 2 | 3 | /* 4 | * See what builds are most commonly invoked by developers, e.g. 'clean assemble', 5 | * 'test' or 'check'. You can set up the URL and a token for your Gradle 6 | * Enterprise instance and run this notebook as-is for your own project. This is a 7 | * simple example of something you can do with the API. It could bring insights, 8 | * for example: 9 | * 10 | * - "Our developers frequently clean together with assemble. We should ask them why, 11 | * because they shouldn't have to. Just an old habit from Maven or are they working 12 | * around a build issue we don't know about?" 13 | * 14 | * - "Some are doing check builds locally, which we set up to trigger our notably slow 15 | * legacy tests. We should suggest they run test instead, leaving check for CI to run." 16 | * 17 | * Run this with at least 1GB of heap to accomodate the fetched data: JAVA_OPTS=-Xmx1g 18 | */ 19 | 20 | @file:DependsOn("com.gabrielfeo:develocity-api-kotlin:2024.3.0") 21 | 22 | import com.gabrielfeo.develocity.api.* 23 | import com.gabrielfeo.develocity.api.model.* 24 | import com.gabrielfeo.develocity.api.extension.* 25 | import kotlinx.coroutines.* 26 | import kotlinx.coroutines.flow.* 27 | import java.time.* 28 | import java.util.LinkedList 29 | 30 | // Parameters 31 | val startDate = LocalDate.now().minusWeeks(1) 32 | val buildFilter: (GradleAttributes) -> Boolean = { build -> 33 | "LOCAL" in build.tags 34 | } 35 | 36 | // Fetch builds from the API 37 | val api = DevelocityApi.newInstance() 38 | val builds: List = runBlocking { 39 | api.buildsApi.getBuildsFlow( 40 | fromInstant = 0, 41 | query = """buildStartTime>-7d""", 42 | models = listOf(BuildModelName.gradleAttributes), 43 | ).map { 44 | it.models!!.gradleAttributes!!.model!! 45 | }.toList(LinkedList()) 46 | } 47 | 48 | // Process builds and count how many times each was invoked 49 | check(builds.isNotEmpty()) { "No builds found. Adjust query and try again." } 50 | val buildCounts = builds.groupBy { build -> 51 | val tasks = build.requestedTasks.joinToString(" ").trim(':') 52 | tasks.ifBlank { "IDE sync" } 53 | }.mapValues { (_, builds) -> 54 | builds.size 55 | }.entries.sortedByDescending { (_, count) -> 56 | count 57 | } 58 | 59 | // Print the top 5 as a pretty table 60 | val table = buildCounts.take(5).joinToString("\n") { (tasks, count) -> 61 | "${tasks.padEnd(100)} | $count" 62 | } 63 | println( 64 | """ 65 | |--------------------- 66 | |Most frequent builds: 67 | | 68 | |$table 69 | """.trimMargin() 70 | ) 71 | 72 | // Shutdown to end background threads and allow script to exit earlier (see README) 73 | api.shutdown() 74 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | group=com.gabrielfeo 2 | artifact=develocity-api-kotlin 3 | version=2025.1.0 4 | develocity.version=2025.1 5 | repo.url=https://github.com/gabrielfeo/develocity-api-kotlin 6 | parallelExamples=false 7 | org.gradle.parallel=true 8 | org.gradle.jvmargs=-Xmx5g 9 | org.gradle.caching=true 10 | # Becomes default in Gradle 9.0 11 | org.gradle.kotlin.dsl.skipMetadataVersionCheck=false 12 | # Explicitly added at different versions, due to Kotlin/kotlin-jupyter#462 13 | kotlin.jupyter.add.api=false 14 | kotlin.jupyter.add.scanner=false 15 | kotlin.jupyter.add.testkit=false 16 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | kotlin = "2.1.20" 3 | dokka = "2.0.0" 4 | openapi-generator = "7.12.0" 5 | jupyter-api = "0.12.0-398" 6 | jupyter-testkit = "0.12.0-398" 7 | jupyter-plugin = "0.12.0-398" 8 | okio = "3.11.0" 9 | moshi = "1.15.2" 10 | okhttp = "4.12.0" 11 | retrofit = "2.11.0" 12 | kotlin-coroutines = "1.10.2" 13 | kotlin-binary-compatibility-validator = "0.17.0" 14 | slf4j = "2.0.17" 15 | guava = "33.4.7-jre" 16 | 17 | [libraries] 18 | okio = { module = "com.squareup.okio:okio", version.ref = "okio" } 19 | moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } 20 | moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" } 21 | okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } 22 | okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } 23 | okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } 24 | retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } 25 | retrofit-converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" } 26 | retrofit-converter-scalars = { module = "com.squareup.retrofit2:converter-scalars", version.ref = "retrofit" } 27 | kotlin-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlin-coroutines" } 28 | kotlin-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlin-coroutines" } 29 | kotlin-jupyter-api = { module = "org.jetbrains.kotlinx:kotlin-jupyter-api", version.ref = "jupyter-api" } 30 | kotlin-jupyter-testkit = { module = "org.jetbrains.kotlinx:kotlin-jupyter-test-kit", version.ref = "jupyter-testkit" } 31 | kotlin-binary-compatibility-validator-plugin = { module = "org.jetbrains.kotlinx:binary-compatibility-validator", version.ref = "kotlin-binary-compatibility-validator" } 32 | kotlin-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } 33 | dokka-plugin = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref = "dokka" } 34 | openapi-generator-plugin = { module = "org.openapitools:openapi-generator-gradle-plugin", version.ref = "openapi-generator" } 35 | slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } 36 | slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } 37 | guava = { module = "com.google.guava:guava", version.ref = "guava" } 38 | 39 | [plugins] 40 | kotlin-jupyter = { id = "org.jetbrains.kotlin.jupyter.api", version.ref = "jupyter-plugin" } 41 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfeo/develocity-api-kotlin/e66a6f4ec578a17c75bdc762f5a1b16c468abb56/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip 5 | networkTimeout=10000 6 | validateDistributionUrl=true 7 | zipStoreBase=GRADLE_USER_HOME 8 | zipStorePath=wrapper/dists 9 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | org.gradle.wrapper.GradleWrapperMain \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /library/.openapi-generator-ignore: -------------------------------------------------------------------------------- 1 | build/generated-api/* 2 | build/generated-api/.* 3 | build/generated-api/.github/* 4 | build/generated-api/api/* 5 | build/generated-api/gradle/**/* 6 | build/generated-api/docs/* 7 | build/generated-api/src/main/AndroidManifest.xml 8 | build/generated-api/src/main/kotlin/com/gabrielfeo/develocity/api/internal/infrastructure/ApiClient.kt 9 | build/generated-api/src/main/kotlin/com/gabrielfeo/develocity/api/GradleEnterpriseApi.kt 10 | build/generated-api/src/main/kotlin/com/gabrielfeo/develocity/api/DevelocityApi.kt 11 | build/generated-api/src/test/**/* 12 | -------------------------------------------------------------------------------- /library/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.gabrielfeo.published-kotlin-jvm-library") 3 | id("com.gabrielfeo.develocity-api-code-generation") 4 | id("com.gabrielfeo.test-suites") 5 | alias(libs.plugins.kotlin.jupyter) 6 | } 7 | 8 | tasks.processJupyterApiResources { 9 | libraryProducers = listOf( 10 | "com.gabrielfeo.develocity.api.internal.jupyter.DevelocityApiJupyterIntegration", 11 | ) 12 | } 13 | 14 | tasks.named("integrationTest") { 15 | environment("DEVELOCITY_API_LOG_LEVEL", "DEBUG") 16 | } 17 | 18 | dependencies { 19 | constraints { 20 | implementation(libs.okio) 21 | } 22 | api(libs.moshi) 23 | implementation(libs.moshi.kotlin) 24 | api(libs.okhttp) 25 | implementation(libs.okhttp.logging.interceptor) 26 | api(libs.retrofit) 27 | implementation(libs.retrofit.converter.moshi) 28 | implementation(libs.retrofit.converter.scalars) 29 | api(libs.kotlin.coroutines) 30 | implementation(libs.slf4j.api) 31 | runtimeOnly(libs.slf4j.simple) 32 | compileOnly(libs.kotlin.jupyter.api) 33 | testImplementation(libs.okhttp.mockwebserver) 34 | testImplementation(libs.okio) 35 | testImplementation(libs.kotlin.coroutines.test) 36 | integrationTestImplementation(libs.kotlin.coroutines.test) 37 | integrationTestImplementation(libs.guava) 38 | integrationTestImplementation(libs.kotlin.jupyter.testkit) 39 | } 40 | 41 | val libraryPom = Action { 42 | name = "Develocity API Kotlin" 43 | description = "A library to use the Develocity API in Kotlin" 44 | val repoUrl = providers.gradleProperty("repo.url") 45 | url = repoUrl 46 | licenses { 47 | license { 48 | name = "MIT" 49 | url = "https://spdx.org/licenses/MIT.html" 50 | distribution = "repo" 51 | } 52 | } 53 | developers { 54 | developer { 55 | id = "gabrielfeo" 56 | name = "Gabriel Feo" 57 | email = "gabriel@gabrielfeo.com" 58 | } 59 | } 60 | scm { 61 | val basicUrl = repoUrl.map { it.substringAfter("://") } 62 | connection = basicUrl.map { "scm:git:git://$it.git" } 63 | developerConnection = basicUrl.map { "scm:git:ssh://$it.git" } 64 | url = basicUrl.map { "https://$it/" } 65 | } 66 | } 67 | 68 | publishing { 69 | publications { 70 | register("develocityApiKotlin") { 71 | artifactId = "develocity-api-kotlin" 72 | from(components["java"]) 73 | pom(libraryPom) 74 | } 75 | // For occasional maven local publishing 76 | register("unsignedDevelocityApiKotlin") { 77 | artifactId = "develocity-api-kotlin" 78 | from(components["java"]) 79 | pom(libraryPom) 80 | } 81 | register("relocation") { 82 | artifactId = "gradle-enterprise-api-kotlin" 83 | pom { 84 | libraryPom(this) 85 | distributionManagement { 86 | relocation { 87 | groupId = project.group.toString() 88 | artifactId = "develocity-api-kotlin" 89 | message = "artifactId has been changed. Part of the rename to Develocity." 90 | } 91 | } 92 | } 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /library/src/integrationTest/kotlin/com/gabrielfeo/develocity/api/DevelocityApiIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api 2 | 3 | import com.gabrielfeo.develocity.api.internal.* 4 | import com.google.common.reflect.ClassPath 5 | import kotlinx.coroutines.test.runTest 6 | import okhttp3.OkHttpClient 7 | import org.junit.jupiter.api.assertDoesNotThrow 8 | import kotlin.reflect.KVisibility.PUBLIC 9 | import kotlin.reflect.full.memberProperties 10 | import kotlin.reflect.javaType 11 | import kotlin.test.* 12 | 13 | @OptIn(ExperimentalStdlibApi::class) 14 | class DevelocityApiIntegrationTest { 15 | 16 | @Test 17 | fun canFetchBuildsWithDefaultConfig() = runTest { 18 | env = RealEnv 19 | val api = DevelocityApi.newInstance( 20 | config = Config( 21 | cacheConfig = Config.CacheConfig(cacheEnabled = false) 22 | ) 23 | ) 24 | val builds = api.buildsApi.getBuilds( 25 | since = 0, 26 | maxBuilds = 5, 27 | query = """buildStartTime>-7d""", 28 | ) 29 | assertEquals(5, builds.size) 30 | api.shutdown() 31 | } 32 | 33 | @Test 34 | fun canBuildNewInstanceWithPureCodeConfiguration() = runTest { 35 | env = FakeEnv() 36 | assertDoesNotThrow { 37 | val config = Config( 38 | apiUrl = "https://google.com/api/", 39 | apiToken = { "" }, 40 | ) 41 | DevelocityApi.newInstance(config) 42 | } 43 | } 44 | 45 | @Test 46 | fun mainApiInterfaceExposesAllGeneratedApiClasses() = runTest { 47 | val generatedApiTypes = getGeneratedApiTypes() 48 | val mainApiInterfaceProperties = getMainApiInterfaceProperties() 49 | generatedApiTypes.forEach { 50 | mainApiInterfaceProperties.singleOrNull { type -> type == it } 51 | ?: fail("No property in DevelocityApi for $it") 52 | } 53 | } 54 | 55 | private fun getGeneratedApiTypes(): List { 56 | val cp = ClassPath.from(this::class.java.classLoader) 57 | return cp.getTopLevelClasses("com.gabrielfeo.develocity.api") 58 | .filter { it.simpleName.endsWith("Api") } 59 | .filter { !it.simpleName.endsWith("DevelocityApi") } 60 | .map { it.name } 61 | } 62 | 63 | private fun getMainApiInterfaceProperties() = DevelocityApi::class.memberProperties 64 | .filter { it.visibility == PUBLIC } 65 | .map { it.returnType.javaType.typeName } 66 | } 67 | -------------------------------------------------------------------------------- /library/src/integrationTest/kotlin/com/gabrielfeo/develocity/api/extension/BuildsApiExtensionsIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api.extension 2 | 3 | import com.gabrielfeo.develocity.api.* 4 | import com.gabrielfeo.develocity.api.internal.* 5 | import com.gabrielfeo.develocity.api.model.BuildModelName 6 | import kotlinx.coroutines.flow.collect 7 | import kotlinx.coroutines.flow.take 8 | import kotlinx.coroutines.test.runTest 9 | import okhttp3.Request 10 | import org.junit.jupiter.api.* 11 | import org.junit.jupiter.api.Assertions.* 12 | import kotlin.test.AfterTest 13 | import kotlin.time.Duration.Companion.minutes 14 | 15 | class BuildsApiExtensionsIntegrationTest { 16 | 17 | init { 18 | env = RealEnv 19 | } 20 | 21 | private val recorder = RequestRecorder() 22 | private val api = buildApi(recorder) 23 | 24 | @AfterTest 25 | fun setup() { 26 | api.shutdown() 27 | } 28 | 29 | @Test 30 | fun getBuildsFlowPreservesParamsAcrossRequests() = runTest(timeout = 6.minutes) { 31 | api.buildsApi.getBuildsFlow( 32 | since = 0, 33 | query = "user:*", 34 | models = listOf(BuildModelName.gradleAttributes), 35 | allModels = true, 36 | reverse = true, 37 | buildsPerPage = 2, 38 | ).take(4).collect() 39 | recorder.requests.forEach { 40 | assertUrlParam(it, "query", "user:*") 41 | assertUrlParam(it, "models", "gradle-attributes") 42 | assertUrlParam(it, "allModels", "true") 43 | assertUrlParam(it, "reverse", "true") 44 | } 45 | } 46 | 47 | @Test 48 | fun getBuildsFlowReplacesSinceForFromBuildAfterFirstRequest() = runTest { 49 | api.buildsApi.getBuildsFlow(since = 1, buildsPerPage = 2).take(10).collect() 50 | assertReplacedForFromBuildAfterFirstRequest(param = "since" to "1") 51 | } 52 | 53 | @Test 54 | fun getBuildsFlowReplacesFromInstantForFromBuildAfterFirstRequest() = runTest { 55 | api.buildsApi.getBuildsFlow(fromInstant = 1, buildsPerPage = 2).take(10).collect() 56 | assertReplacedForFromBuildAfterFirstRequest(param = "fromInstant" to "1") 57 | } 58 | 59 | private fun assertReplacedForFromBuildAfterFirstRequest(param: Pair) { 60 | with(recorder.requests) { 61 | val (key, value) = param 62 | first().let { 63 | assertUrlParam(it, key, value) 64 | assertUrlParam(it, "fromBuild", null) 65 | } 66 | (this - first()).forEach { 67 | assertUrlParam(it, key, null) 68 | assertUrlParamNotNull(it, "fromBuild") 69 | } 70 | } 71 | } 72 | 73 | private fun buildApi(recorder: RequestRecorder) = 74 | DevelocityApi.newInstance( 75 | config = Config( 76 | clientBuilder = recorder.clientBuilder(), 77 | cacheConfig = Config.CacheConfig(cacheEnabled = false), 78 | ) 79 | ) 80 | 81 | private fun assertUrlParam(request: Request, key: String, expected: String?) { 82 | val actual = request.url.queryParameter(key) 83 | assertEquals(expected, actual, "Expected '$key='$expected', but was '$key=$actual' (${request.url})") 84 | } 85 | 86 | private fun assertUrlParamNotNull(request: Request, key: String) { 87 | assertNotNull(request.url.queryParameter(key), "Expected param $key, but was null (${request.url})") 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /library/src/integrationTest/kotlin/com/gabrielfeo/develocity/api/extension/RequestRecorder.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api.extension 2 | 3 | import okhttp3.OkHttpClient 4 | import okhttp3.Request 5 | import java.util.* 6 | 7 | class RequestRecorder { 8 | 9 | val requests = LinkedList() 10 | 11 | fun clientBuilder() = OkHttpClient.Builder() 12 | .addNetworkInterceptor { 13 | requests += it.request() 14 | it.proceed(it.request()) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /library/src/integrationTest/kotlin/com/gabrielfeo/develocity/api/internal/jupyter/DevelocityApiJupyterIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api.internal.jupyter 2 | 3 | import com.google.common.reflect.ClassPath 4 | import com.google.common.reflect.ClassPath.ClassInfo 5 | import org.intellij.lang.annotations.Language 6 | import org.jetbrains.kotlinx.jupyter.api.Code 7 | import org.jetbrains.kotlinx.jupyter.testkit.JupyterReplTestCase 8 | import kotlin.reflect.KVisibility 9 | import kotlin.test.Test 10 | 11 | @ExperimentalStdlibApi 12 | class DevelocityApiJupyterIntegrationTest : JupyterReplTestCase() { 13 | 14 | @Test 15 | fun `imports all extensions`() = assertSucceeds(""" 16 | com.gabrielfeo.develocity.api.BuildsApi::getGradleAttributesFlow 17 | com.gabrielfeo.develocity.api.BuildsApi::getBuildsFlow 18 | 19 | val attrs = emptyList() 20 | "custom value name" in attrs 21 | attrs["custom value name"] 22 | """) 23 | 24 | @Test 25 | fun `imports all public classes`() { 26 | val classes = allPublicClassesRecursive("com.gabrielfeo.develocity.api") 27 | val references = classes.joinToString("\n") { "${it.name}::class" } 28 | println("Running code:\n$references") 29 | assertSucceeds(references) 30 | } 31 | 32 | @Suppress("SameParameterValue") 33 | private fun allPublicClassesRecursive(packageName: String): List { 34 | val cp = ClassPath.from(this::class.java.classLoader) 35 | return cp.getTopLevelClassesRecursive(packageName) 36 | .filter { "internal" !in it.packageName } 37 | .filter { !it.name.endsWith("Kt") } 38 | .filter { Class.forName(it.name).kotlin.visibility == KVisibility.PUBLIC } 39 | } 40 | 41 | private fun assertSucceeds(@Language("kts") code: Code) { 42 | code.lines().forEach { 43 | execRendered(it) 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /library/src/main/kotlin/com/gabrielfeo/develocity/api/Config.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api 2 | 3 | import com.gabrielfeo.develocity.api.Config.CacheConfig 4 | import com.gabrielfeo.develocity.api.internal.* 5 | import okhttp3.Dispatcher 6 | import okhttp3.OkHttpClient 7 | import java.io.File 8 | import kotlin.time.Duration.Companion.days 9 | 10 | /** 11 | * Library configuration options. 12 | */ 13 | @Suppress("MemberVisibilityCanBePrivate", "unused") 14 | data class Config( 15 | 16 | /** 17 | * Changes minimum log level for library classes, including the HTTP 18 | * client, **when using `slf4j-simple`** (bundled with the library). If 19 | * replacing SLF4J bindings, this setting has no effect, and log level 20 | * must be changed in the chosen logging framework. 21 | * 22 | * Default value, by order of precedence: 23 | * 24 | * - `DEVELOCITY_API_LOG_LEVEL` environment variable 25 | * - `org.slf4j.simpleLogger.defaultLogLevel` system property 26 | * - `"off"` 27 | * 28 | * SLF4J valid log levels and their usage by the library: 29 | * 30 | * - "off" (default, no logs) 31 | * - "error" 32 | * - "warn" 33 | * - "info" 34 | * - "debug" (logs HTTP traffic: URLs and status codes only) 35 | * - "trace" (logs HTTP traffic: full request and response including body, excluding 36 | * authorization header) 37 | */ 38 | val logLevel: String = 39 | env["DEVELOCITY_API_LOG_LEVEL"] 40 | ?: systemProperties.logLevel 41 | ?: "off", 42 | 43 | /** 44 | * Provides the URL of a Develocity API instance REST API. By default, uses 45 | * environment variable `DEVELOCITY_API_URL`. Must end with `/api/`. 46 | */ 47 | val apiUrl: String = 48 | env["DEVELOCITY_API_URL"] 49 | ?: error(ERROR_NULL_API_URL), 50 | 51 | /** 52 | * Provides the access token for a Develocity API instance. By default, uses environment 53 | * variable `DEVELOCITY_API_TOKEN`. 54 | */ 55 | val apiToken: () -> String = { 56 | env["DEVELOCITY_API_TOKEN"] 57 | ?: error(ERROR_NULL_API_TOKEN) 58 | }, 59 | 60 | /** 61 | * [OkHttpClient.Builder] to use when building the library's internal [OkHttpClient]. 62 | * 63 | * This is aimed at using the library inside a full Kotlin project. Allows the internal client 64 | * to share resources such as thread pools with another [OkHttpClient]. See [OkHttpClient] 65 | * for all that is shared. 66 | * 67 | * The default is to share resources only within the library, i.e. multiple `Config()` with 68 | * the default [clientBuilder] will already share resources. 69 | */ 70 | val clientBuilder: OkHttpClient.Builder = basicOkHttpClient.newBuilder(), 71 | 72 | /** 73 | * Maximum amount of concurrent requests allowed. Further requests will be queued. By default, 74 | * uses environment variable `DEVELOCITY_API_MAX_CONCURRENT_REQUESTS` or 5 (OkHttp's 75 | * default value of [Dispatcher.maxRequestsPerHost]). 76 | * 77 | * If set, will set [Dispatcher.maxRequests] and [Dispatcher.maxRequestsPerHost] of the 78 | * internal client, overwriting what's inherited from the base client of [clientBuilder], 79 | * if any. 80 | */ 81 | val maxConcurrentRequests: Int? = 82 | env["DEVELOCITY_API_MAX_CONCURRENT_REQUESTS"]?.toInt(), 83 | 84 | /** 85 | * Timeout for reading an API response, used for [OkHttpClient.readTimeoutMillis]. 86 | * By default, uses environment variable `DEVELOCITY_API_READ_TIMEOUT_MILLIS` 87 | * or 60_000. Keep in mind that Develocity API responses can be big and slow to send depending on 88 | * the endpoint. 89 | */ 90 | val readTimeoutMillis: Long = 91 | env["DEVELOCITY_API_READ_TIMEOUT_MILLIS"]?.toLong() 92 | ?: 60_000L, 93 | 94 | /** 95 | * See [CacheConfig]. 96 | */ 97 | val cacheConfig: CacheConfig = 98 | CacheConfig(), 99 | ) { 100 | 101 | /** 102 | * HTTP cache is off by default, but can speed up requests significantly. The Develocity 103 | * API disallows HTTP caching, but this library forcefully enables it by overwriting 104 | * cache-related headers in API responses. Enable with [cacheEnabled]. 105 | * 106 | * Responses can be: 107 | * 108 | * - cached short-term: default max-age of 1 day 109 | * - `/api/builds` 110 | * - cached long-term: default max-age of 1 year 111 | * - `/api/builds/{id}/gradle-attributes` 112 | * - `/api/builds/{id}/maven-attributes` 113 | * - `/api/builds/{id}/gradle-build-cache-performance` 114 | * - `/api/builds/{id}/maven-build-cache-performance` 115 | * - not cached 116 | * - all other paths 117 | * 118 | * Whether a response is cached short-term, long-term or not cached at 119 | * all depends on whether it was matched by [shortTermCacheUrlPattern] or 120 | * [longTermCacheUrlPattern]. 121 | * 122 | * Whenever Develocity is upgraded, cache should be [clear]ed. 123 | * 124 | * ### Caveats 125 | * 126 | * While not encouraged by the API, caching shouldn't have any major downsides other than a 127 | * time gap for certain queries, or having to reset cache when Develocity is upgraded. 128 | * 129 | * #### Time gap 130 | * 131 | * `/api/builds` responses always change as new builds are uploaded. Caching this path 132 | * short-term (default 1 day) means new builds uploaded after the cached response won't be 133 | * included in the query until the cache is invalidated 24h later. If that's a problem, 134 | * caching can be disabled for this `/api/builds` by changing [shortTermCacheUrlPattern]. 135 | * 136 | * #### Develocity upgrades 137 | * 138 | * When Develocity is upgraded, any API response can change. New data might be available in API 139 | * endpoints such as `/api/build/{id}/gradle-attributes`. Thus, whenever the Develocity version 140 | * itself is upgraded, cache should be [clear]ed. 141 | */ 142 | @Suppress("MemberVisibilityCanBePrivate") 143 | data class CacheConfig( 144 | 145 | /** 146 | * Whether caching is enabled. By default, uses environment variable 147 | * `DEVELOCITY_API_CACHE_ENABLED` or `false`. 148 | */ 149 | val cacheEnabled: Boolean = 150 | env["DEVELOCITY_API_CACHE_ENABLED"].toBoolean(), 151 | 152 | /** 153 | * HTTP cache location. By default, uses environment variable `DEVELOCITY_API_CACHE_DIR` 154 | * or creates a `~/.develocity-api-kotlin-cache` directory. 155 | */ 156 | val cacheDir: File = 157 | env["DEVELOCITY_API_CACHE_DIR"]?.let(::File) 158 | ?: run { 159 | val userHome = checkNotNull(systemProperties.userHome) { ERROR_NULL_USER_HOME } 160 | File(userHome, ".develocity-api-kotlin-cache") 161 | }, 162 | 163 | /** 164 | * Max size of the HTTP cache. By default, uses environment variable 165 | * `DEVELOCITY_API_MAX_CACHE_SIZE` or ~1 GB. 166 | */ 167 | val maxCacheSize: Long = env["DEVELOCITY_API_MAX_CACHE_SIZE"]?.toLong() 168 | ?: 1_000_000_000L, 169 | 170 | /** 171 | * Regex pattern to match API URLs that are OK to store long-term in the HTTP cache, up to 172 | * [longTermCacheMaxAge] (1y by default, max value). By default, uses environment variable 173 | * `DEVELOCITY_API_LONG_TERM_CACHE_URL_PATTERN` or a pattern matching: 174 | * - {host}/api/builds/{id}/gradle-attributes 175 | * - {host}/api/builds/{id}/maven-attributes 176 | * - {host}/api/builds/{id}/gradle-build-cache-performance 177 | * - {host}/api/builds/{id}/maven-build-cache-performance 178 | * 179 | * Use `|` to define multiple patterns in one, e.g. `.*gradle-attributes|.*test-distribution`. 180 | */ 181 | val longTermCacheUrlPattern: Regex = 182 | env["DEVELOCITY_API_LONG_TERM_CACHE_URL_PATTERN"]?.toRegex() 183 | ?: Regex( 184 | """ 185 | .*/api/builds/[\d\w]+/(?:gradle|maven)-(?:attributes|build-cache-performance) 186 | """.trimIndent() 187 | ), 188 | 189 | /** 190 | * Max age in seconds for URLs to be cached long-term (matched by [longTermCacheUrlPattern]). 191 | * By default, uses environment variable `DEVELOCITY_API_LONG_TERM_CACHE_MAX_AGE` or 1 year. 192 | */ 193 | val longTermCacheMaxAge: Long = 194 | env["DEVELOCITY_API_SHORT_TERM_CACHE_MAX_AGE"]?.toLong() 195 | ?: 365.days.inWholeSeconds, 196 | 197 | /** 198 | * Regex pattern to match API URLs that are OK to store short-term in the HTTP cache, up to 199 | * [shortTermCacheMaxAge] (1d by default). By default, uses environment variable 200 | * `DEVELOCITY_API_SHORT_TERM_CACHE_URL_PATTERN` or a pattern matching: 201 | * - {host}/api/builds 202 | * 203 | * Use `|` to define multiple patterns in one, e.g. `.*gradle-attributes|.*test-distribution`. 204 | */ 205 | val shortTermCacheUrlPattern: Regex = 206 | env["DEVELOCITY_API_SHORT_TERM_CACHE_URL_PATTERN"]?.toRegex() 207 | ?: """.*/builds(?:\?.*|\Z)""".toRegex(), 208 | 209 | /** 210 | * Max age in seconds for URLs to be cached short-term (matched by [shortTermCacheUrlPattern]). 211 | * By default, uses environment variable `DEVELOCITY_API_SHORT_TERM_CACHE_MAX_AGE` or 1 day. 212 | */ 213 | val shortTermCacheMaxAge: Long = 214 | env["DEVELOCITY_API_SHORT_TERM_CACHE_MAX_AGE"]?.toLong() 215 | ?: 1.days.inWholeSeconds, 216 | ) 217 | } 218 | 219 | private const val ERROR_NULL_API_URL = "DEVELOCITY_API_URL is required" 220 | private const val ERROR_NULL_API_TOKEN = "DEVELOCITY_API_TOKEN is required" 221 | private const val ERROR_NULL_USER_HOME = "'user.home' system property must not be null" 222 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/gabrielfeo/develocity/api/DevelocityApi.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api 2 | 3 | import com.gabrielfeo.develocity.api.internal.RealLoggerFactory 4 | import com.gabrielfeo.develocity.api.internal.buildOkHttpClient 5 | import com.gabrielfeo.develocity.api.internal.buildRetrofit 6 | import com.gabrielfeo.develocity.api.internal.infrastructure.Serializer 7 | import okhttp3.OkHttpClient 8 | import retrofit2.Retrofit 9 | import retrofit2.create 10 | 11 | /** 12 | * Develocity API client. API endpoints are grouped exactly as in the 13 | * [Develocity API Manual](https://docs.gradle.com/enterprise/api-manual/#reference_documentation): 14 | * 15 | * - [buildsApi] 16 | * - [buildCacheApi] 17 | * - [metaApi] 18 | * - [testDistributionApi] 19 | * 20 | * Create an instance with [newInstance]: 21 | * 22 | * ```kotlin 23 | * val api = DevelocityApi.newInstance() 24 | * api.buildsApi.getBuilds(...) 25 | * ``` 26 | * 27 | * You may pass a default [Config], e.g. for sharing [OkHttpClient] resources: 28 | * 29 | * ```kotlin 30 | * val options = Options(clientBuilder = myOwnOkHttpClient.newBuilder()) 31 | * val api = DevelocityApi.newInstance(options) 32 | * api.buildsApi.getBuilds(...) 33 | * ``` 34 | */ 35 | interface DevelocityApi { 36 | 37 | val authApi: AuthApi 38 | val buildsApi: BuildsApi 39 | val buildCacheApi: BuildCacheApi 40 | val projectsApi: ProjectsApi 41 | val testsApi: TestsApi 42 | val metaApi: MetaApi 43 | val testDistributionApi: TestDistributionApi 44 | 45 | /** 46 | * Library configuration options. 47 | */ 48 | val config: Config 49 | 50 | /** 51 | * Release resources allowing the program to finish before the internal client's idle timeout. 52 | */ 53 | fun shutdown() 54 | 55 | companion object { 56 | 57 | /** 58 | * Create a new instance of `DevelocityApi` with a custom `Config`. 59 | */ 60 | fun newInstance(config: Config = Config()): DevelocityApi { 61 | return RealDevelocityApi(config) 62 | } 63 | } 64 | 65 | } 66 | 67 | internal class RealDevelocityApi( 68 | override val config: Config, 69 | ) : DevelocityApi { 70 | 71 | private val okHttpClient by lazy { 72 | buildOkHttpClient(config = config, RealLoggerFactory(config)) 73 | } 74 | 75 | private val retrofit: Retrofit by lazy { 76 | buildRetrofit( 77 | config, 78 | okHttpClient, 79 | Serializer.moshi, 80 | ) 81 | } 82 | 83 | override val authApi: AuthApi by lazy { retrofit.create() } 84 | override val buildsApi: BuildsApi by lazy { retrofit.create() } 85 | override val buildCacheApi: BuildCacheApi by lazy { retrofit.create() } 86 | override val projectsApi: ProjectsApi by lazy { retrofit.create() } 87 | override val testsApi: TestsApi by lazy { retrofit.create() } 88 | override val metaApi: MetaApi by lazy { retrofit.create() } 89 | override val testDistributionApi: TestDistributionApi by lazy { retrofit.create() } 90 | 91 | override fun shutdown() { 92 | okHttpClient.run { 93 | dispatcher.executorService.shutdown() 94 | connectionPool.evictAll() 95 | cache?.close() 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/gabrielfeo/develocity/api/extension/BuildAttributesValueExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api.extension 2 | 3 | import com.gabrielfeo.develocity.api.model.BuildAttributesValue 4 | 5 | operator fun List.get(name: String): String? { 6 | return find { it.name == name }?.value 7 | } 8 | 9 | operator fun List.contains(name: String): Boolean { 10 | return any { it.name == name } 11 | } 12 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/gabrielfeo/develocity/api/extension/BuildsApiExtensions.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("unused") 2 | 3 | package com.gabrielfeo.develocity.api.extension 4 | 5 | import com.gabrielfeo.develocity.api.Config 6 | import com.gabrielfeo.develocity.api.BuildsApi 7 | import com.gabrielfeo.develocity.api.internal.API_MAX_BUILDS 8 | import com.gabrielfeo.develocity.api.model.* 9 | import kotlinx.coroutines.CoroutineScope 10 | import kotlinx.coroutines.DelicateCoroutinesApi 11 | import kotlinx.coroutines.GlobalScope 12 | import kotlinx.coroutines.flow.* 13 | 14 | /** 15 | * Gets builds on demand from the API, in as many requests as necessary. It allows 16 | * for queries of any size, as opposed to [BuildsApi.getBuilds] which is limited by the 17 | * API itself to 1000. 18 | * 19 | * - Will request from the API until results end, collection stops or an error occurs. 20 | * - Parameters same as [BuildsApi.getBuilds]. 21 | * - Using [query] is highly recommended for server-side filtering (equivalent to Develocity advanced 22 | * query). 23 | * - `maxBuilds` is the only unsupported parameter, because this Flow will instead fetch 24 | * continously. Use [Flow.take] to stop collecting at a specific count. 25 | */ 26 | fun BuildsApi.getBuildsFlow( 27 | since: Long? = null, 28 | sinceBuild: String? = null, 29 | fromInstant: Long? = null, 30 | fromBuild: String? = null, 31 | query: String? = null, 32 | reverse: Boolean? = null, 33 | maxWaitSecs: Int? = null, 34 | buildsPerPage: Int = API_MAX_BUILDS, 35 | models: List? = null, 36 | allModels: Boolean? = false, 37 | ): Flow { 38 | return flow { 39 | var builds = getBuilds( 40 | since = since, 41 | sinceBuild = sinceBuild, 42 | fromInstant = fromInstant, 43 | fromBuild = fromBuild, 44 | query = query, 45 | reverse = reverse, 46 | maxWaitSecs = maxWaitSecs, 47 | maxBuilds = buildsPerPage, 48 | models = models, 49 | allModels = allModels, 50 | ) 51 | emitAll(builds.asFlow()) 52 | while (builds.isNotEmpty()) { 53 | builds = getBuilds( 54 | fromBuild = builds.last().id, 55 | query = query, 56 | reverse = reverse, 57 | maxWaitSecs = maxWaitSecs, 58 | maxBuilds = buildsPerPage, 59 | models = models, 60 | allModels = allModels, 61 | ) 62 | emitAll(builds.asFlow()) 63 | } 64 | } 65 | } 66 | 67 | /** 68 | * Gets [GradleAttributes] of all builds from a given date. Queries [BuildsApi.getBuilds] first, 69 | * the endpoint providing a timeline of builds, then maps each to [BuildsApi.getGradleAttributes]. 70 | * 71 | * Instead of filtering builds downstream based on `GradleAttributes` (e.g. using [Flow.filter]), 72 | * prefer filtering server-side using a `query` (see [BuildsApi.getBuilds]). 73 | * 74 | * ### Buffering 75 | * 76 | * Will request eagerly and buffer up to [Int.MAX_VALUE] calls. 77 | * 78 | * ### Concurrency 79 | * 80 | * Attributes are requested concurrently in coroutines started in [scope]. The number of 81 | * concurrent requests underneath is still limited by [Config.maxConcurrentRequests]. 82 | * 83 | * @param scope CoroutineScope in which to create coroutines. Defaults to [GlobalScope]. 84 | */ 85 | @Deprecated( 86 | "Use `getBuildsFlow(models = listOf(BuildModelName.gradleAttributes))` instead. " + 87 | "This function will be removed in the next release.", 88 | replaceWith = ReplaceWith( 89 | "getBuildsFlow(since, sinceBuild, fromInstant, fromBuild, query, reverse," + 90 | "maxWaitSecs, models = listOf(BuildModelName.gradleAttributes))", 91 | imports = [ 92 | "com.gabrielfeo.develocity.api.extension.getBuildsFlow", 93 | "com.gabrielfeo.develocity.api.model.BuildModelName", 94 | ] 95 | ), 96 | ) 97 | @OptIn(DelicateCoroutinesApi::class) 98 | fun BuildsApi.getGradleAttributesFlow( 99 | since: Long = 0, 100 | sinceBuild: String? = null, 101 | fromInstant: Long? = null, 102 | fromBuild: String? = null, 103 | query: String? = null, 104 | reverse: Boolean? = null, 105 | maxWaitSecs: Int? = null, 106 | scope: CoroutineScope = GlobalScope, 107 | models: List? = null, 108 | ): Flow = 109 | getBuildsFlow( 110 | since = since, 111 | sinceBuild = sinceBuild, 112 | fromInstant = fromInstant, 113 | fromBuild = fromBuild, 114 | query = query, 115 | reverse = reverse, 116 | maxWaitSecs = maxWaitSecs, 117 | models = models, 118 | ).withGradleAttributes(scope, api = this).map { (_, attrs) -> 119 | attrs 120 | } 121 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/gabrielfeo/develocity/api/extension/Mapping.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api.extension 2 | 3 | import com.gabrielfeo.develocity.api.* 4 | import com.gabrielfeo.develocity.api.model.* 5 | import kotlinx.coroutines.* 6 | import kotlinx.coroutines.flow.* 7 | 8 | /** 9 | * Joins builds with their [GradleAttributes], which comes from a different endpoint 10 | * ([BuildsApi.getGradleAttributes]). 11 | * 12 | * Don't expect client-side filtering to be efficient. Does as many concurrent calls 13 | * as it can, requesting attributes in an eager coroutine, in [scope]. 14 | */ 15 | internal fun Flow.withGradleAttributes( 16 | scope: CoroutineScope, 17 | api: BuildsApi, 18 | ): Flow> = 19 | map { build -> 20 | build to scope.async { 21 | api.getGradleAttributes(build.id) 22 | } 23 | }.buffer(Int.MAX_VALUE).map { (build, attrs) -> 24 | build to attrs.await() 25 | } 26 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/gabrielfeo/develocity/api/internal/ApiConstants.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api.internal 2 | 3 | /** 4 | * Undocumented max value of `/api/builds?maxBuilds`. Last checked in 2022.4. 5 | */ 6 | internal const val API_MAX_BUILDS = 1000 7 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/gabrielfeo/develocity/api/internal/Env.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api.internal 2 | 3 | internal var env: Env = RealEnv 4 | 5 | internal interface Env { 6 | operator fun get(name: String): String? 7 | } 8 | 9 | internal object RealEnv : Env { 10 | override fun get(name: String): String? = System.getenv(name) 11 | } 12 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/gabrielfeo/develocity/api/internal/LoggerFactory.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api.internal 2 | 3 | import com.gabrielfeo.develocity.api.Config 4 | import org.slf4j.Logger 5 | import kotlin.reflect.KClass 6 | 7 | internal interface LoggerFactory { 8 | fun newLogger(cls: KClass<*>): Logger 9 | } 10 | 11 | internal class RealLoggerFactory( 12 | private val config: Config, 13 | ) : LoggerFactory { 14 | 15 | override fun newLogger(cls: KClass<*>): Logger { 16 | setLogLevel() 17 | return org.slf4j.LoggerFactory.getLogger(cls.java) 18 | } 19 | 20 | private fun setLogLevel() { 21 | System.setProperty(LOG_LEVEL_SYSTEM_PROPERTY, config.logLevel) 22 | } 23 | 24 | companion object { 25 | const val LOG_LEVEL_SYSTEM_PROPERTY = "org.slf4j.simpleLogger.defaultLogLevel" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/gabrielfeo/develocity/api/internal/OkHttpClient.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api.internal 2 | 3 | import com.gabrielfeo.develocity.api.Config 4 | import com.gabrielfeo.develocity.api.internal.auth.HttpBearerAuth 5 | import com.gabrielfeo.develocity.api.internal.caching.CacheEnforcingInterceptor 6 | import com.gabrielfeo.develocity.api.internal.caching.CacheHitLoggingInterceptor 7 | import okhttp3.Cache 8 | import okhttp3.Interceptor 9 | import okhttp3.OkHttpClient 10 | import okhttp3.logging.HttpLoggingInterceptor 11 | import okhttp3.logging.HttpLoggingInterceptor.Level.BASIC 12 | import okhttp3.logging.HttpLoggingInterceptor.Level.BODY 13 | import java.time.Duration 14 | import org.slf4j.Logger 15 | 16 | /** 17 | * Base instance just so that multiple created [Config]s will share resources by default. 18 | */ 19 | internal val basicOkHttpClient by lazy { 20 | OkHttpClient.Builder().build() 21 | } 22 | 23 | /** 24 | * Builds the final `OkHttpClient` with a `Config`. 25 | */ 26 | internal fun buildOkHttpClient( 27 | config: Config, 28 | loggerFactory: LoggerFactory, 29 | ) = with(config.clientBuilder) { 30 | readTimeout(Duration.ofMillis(config.readTimeoutMillis)) 31 | if (config.cacheConfig.cacheEnabled) { 32 | cache(buildCache(config, loggerFactory)) 33 | } 34 | addInterceptors(config, loggerFactory) 35 | addNetworkInterceptors(config, loggerFactory) 36 | build().apply { 37 | config.maxConcurrentRequests?.let { 38 | dispatcher.maxRequests = it 39 | dispatcher.maxRequestsPerHost = it 40 | } 41 | } 42 | } 43 | 44 | private fun OkHttpClient.Builder.addInterceptors( 45 | config: Config, 46 | loggerFactory: LoggerFactory, 47 | ) { 48 | if (config.cacheConfig.cacheEnabled) { 49 | val logger = loggerFactory.newLogger(CacheHitLoggingInterceptor::class) 50 | addInterceptor(CacheHitLoggingInterceptor(logger)) 51 | } 52 | } 53 | 54 | private fun OkHttpClient.Builder.addNetworkInterceptors( 55 | config: Config, 56 | loggerFactory: LoggerFactory, 57 | ) { 58 | if (config.cacheConfig.cacheEnabled) { 59 | addNetworkInterceptor(buildCacheEnforcingInterceptor(config)) 60 | } 61 | val logger = loggerFactory.newLogger(HttpLoggingInterceptor::class) 62 | addNetworkInterceptor(HttpLoggingInterceptor(logger = logger::debug).apply { level = BASIC }) 63 | addNetworkInterceptor(HttpLoggingInterceptor(logger = logger::trace).apply { level = BODY }) 64 | addNetworkInterceptor(HttpBearerAuth("bearer", config.apiToken())) 65 | } 66 | 67 | internal fun buildCache( 68 | config: Config, 69 | loggerFactory: LoggerFactory, 70 | ): Cache { 71 | val cacheDir = config.cacheConfig.cacheDir 72 | val maxSize = config.cacheConfig.maxCacheSize 73 | val logger = loggerFactory.newLogger(Cache::class) 74 | logger.debug("HTTP cache dir: {} (max {}B)", cacheDir, maxSize) 75 | return Cache(cacheDir, maxSize) 76 | } 77 | 78 | private fun buildCacheEnforcingInterceptor( 79 | config: Config, 80 | ) = CacheEnforcingInterceptor( 81 | longTermCacheUrlPattern = config.cacheConfig.longTermCacheUrlPattern, 82 | longTermCacheMaxAge = config.cacheConfig.longTermCacheMaxAge, 83 | shortTermCacheUrlPattern = config.cacheConfig.shortTermCacheUrlPattern, 84 | shortTermCacheMaxAge = config.cacheConfig.shortTermCacheMaxAge, 85 | ) 86 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/gabrielfeo/develocity/api/internal/Retrofit.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api.internal 2 | 3 | import com.gabrielfeo.develocity.api.Config 4 | import com.squareup.moshi.Moshi 5 | import okhttp3.OkHttpClient 6 | import retrofit2.Retrofit 7 | import retrofit2.converter.moshi.MoshiConverterFactory 8 | import retrofit2.converter.scalars.ScalarsConverterFactory 9 | 10 | internal fun buildRetrofit( 11 | config: Config, 12 | client: OkHttpClient, 13 | moshi: Moshi, 14 | ) = with(Retrofit.Builder()) { 15 | val url = config.apiUrl 16 | check("/api/" in url) { "A valid API URL must end in /api/" } 17 | val instanceUrl = url.substringBefore("api/") 18 | baseUrl(instanceUrl) 19 | addConverterFactory(ScalarsConverterFactory.create()) 20 | addConverterFactory(MoshiConverterFactory.create(moshi)) 21 | client(client) 22 | build() 23 | } 24 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/gabrielfeo/develocity/api/internal/SystemProperties.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api.internal 2 | 3 | internal var systemProperties: SystemProperties = RealSystemProperties 4 | 5 | internal interface SystemProperties { 6 | val userHome: String? 7 | val logLevel: String? 8 | } 9 | 10 | internal object RealSystemProperties : SystemProperties { 11 | override val userHome: String? = System.getProperty("user.home") 12 | override val logLevel: String? = System.getProperty(RealLoggerFactory.LOG_LEVEL_SYSTEM_PROPERTY) 13 | } 14 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/gabrielfeo/develocity/api/internal/caching/CacheEnforcingInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api.internal.caching 2 | 3 | import okhttp3.Interceptor 4 | import okhttp3.Request 5 | import okhttp3.Response 6 | 7 | 8 | internal class CacheEnforcingInterceptor( 9 | private val longTermCacheUrlPattern: Regex, 10 | private val longTermCacheMaxAge: Long, 11 | private val shortTermCacheUrlPattern: Regex, 12 | private val shortTermCacheMaxAge: Long, 13 | ) : Interceptor { 14 | 15 | override fun intercept(chain: Interceptor.Chain): Response { 16 | val response = chain.proceed(chain.request()) 17 | val maxAge = maxAgeFor(response.request) 18 | if (maxAge <= 0) { 19 | return response 20 | } 21 | return response.newBuilder() 22 | .header("cache-control", "max-age=$maxAge") 23 | .removeHeader("pragma") 24 | .removeHeader("expires") 25 | .removeHeader("vary") 26 | .build() 27 | } 28 | 29 | private fun maxAgeFor(request: Request): Long { 30 | val url = request.url.toString() 31 | return when { 32 | longTermCacheUrlPattern.matches(url) -> longTermCacheMaxAge 33 | shortTermCacheUrlPattern.matches(url) -> shortTermCacheMaxAge 34 | else -> 0 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/gabrielfeo/develocity/api/internal/caching/CacheHitLoggingInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api.internal.caching 2 | 3 | import okhttp3.Interceptor 4 | import okhttp3.Response 5 | import org.slf4j.Logger 6 | 7 | 8 | internal class CacheHitLoggingInterceptor( 9 | private val logger: Logger, 10 | ) : Interceptor { 11 | 12 | override fun intercept(chain: Interceptor.Chain): Response { 13 | val url = chain.request().url 14 | val response = chain.proceed(chain.request()) 15 | val wasHit = with(response) { cacheResponse != null && networkResponse == null } 16 | val hitOrMiss = if (wasHit) "hit" else "miss" 17 | logger.debug("Cache {}: {}", hitOrMiss, url) 18 | return response 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/gabrielfeo/develocity/api/internal/jupyter/DevelocityApiJupyterIntegration.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api.internal.jupyter 2 | 3 | import org.jetbrains.kotlinx.jupyter.api.libraries.JupyterIntegration 4 | 5 | @Suppress("unused") 6 | class DevelocityApiJupyterIntegration : JupyterIntegration() { 7 | 8 | override fun Builder.onLoaded() { 9 | import("com.gabrielfeo.develocity.api.*") 10 | import("com.gabrielfeo.develocity.api.model.*") 11 | import("com.gabrielfeo.develocity.api.extension.*") 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /library/src/test/kotlin/com/gabrielfeo/develocity/api/CacheConfigTest.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api 2 | 3 | import com.gabrielfeo.develocity.api.internal.* 4 | import kotlin.test.* 5 | 6 | class CacheConfigTest { 7 | 8 | @BeforeTest 9 | fun before() { 10 | env = FakeEnv("DEVELOCITY_API_URL" to "https://example.com/api/") 11 | } 12 | 13 | @Test 14 | fun `default longTermCacheUrlPattern matches attributes URLs`() { 15 | Config.CacheConfig().longTermCacheUrlPattern.assertMatches( 16 | "https://ge.gradle.org/api/builds/tgnsqkb2rhlni/gradle-attributes", 17 | "https://ge.gradle.org/api/builds/tgnsqkb2rhlni/maven-attributes", 18 | ) 19 | } 20 | 21 | @Test 22 | fun `default longTermCacheUrlPattern matches build cache performance URLs`() { 23 | Config.CacheConfig().longTermCacheUrlPattern.assertMatches( 24 | "https://ge.gradle.org/api/builds/tgnsqkb2rhlni/gradle-build-cache-performance", 25 | "https://ge.gradle.org/api/builds/tgnsqkb2rhlni/maven-build-cache-performance", 26 | ) 27 | } 28 | 29 | @Test 30 | fun `default shortTermCacheUrlPattern matches builds URLs`() { 31 | Config.CacheConfig().shortTermCacheUrlPattern.assertMatches( 32 | "https://ge.gradle.org/api/builds?since=0", 33 | "https://ge.gradle.org/api/builds?since=0&maxBuilds=2", 34 | ) 35 | } 36 | 37 | private fun Regex.assertMatches(vararg values: String) { 38 | values.forEach { 39 | assertTrue(matches(it), "/$pattern/ doesn't match '$it'") 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /library/src/test/kotlin/com/gabrielfeo/develocity/api/ConfigTest.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api 2 | 3 | import com.gabrielfeo.develocity.api.internal.* 4 | import org.junit.jupiter.api.assertDoesNotThrow 5 | import kotlin.test.* 6 | 7 | class ConfigTest { 8 | 9 | @BeforeTest 10 | fun before() { 11 | env = FakeEnv("DEVELOCITY_API_URL" to "https://example.com/api/") 12 | systemProperties = FakeSystemProperties() 13 | } 14 | 15 | @Test 16 | fun `Given no URL set in env, error`() { 17 | env = FakeEnv() 18 | assertFails { 19 | Config() 20 | } 21 | } 22 | 23 | @Test 24 | fun `Given URL set in env, apiUrl is env URL`() { 25 | (env as FakeEnv)["DEVELOCITY_API_URL"] = "https://example.com/api/" 26 | assertEquals("https://example.com/api/", Config().apiUrl) 27 | } 28 | 29 | @Test 30 | fun `Given no token, error`() { 31 | assertFails { 32 | Config().apiToken() 33 | } 34 | } 35 | 36 | @Test 37 | fun `Given token set in env, apiToken is env token`() { 38 | (env as FakeEnv)["DEVELOCITY_API_TOKEN"] = "bar" 39 | assertEquals("bar", Config().apiToken()) 40 | } 41 | 42 | @Test 43 | fun `maxConcurrentRequests accepts int`() { 44 | (env as FakeEnv)["DEVELOCITY_API_MAX_CONCURRENT_REQUESTS"] = "1" 45 | assertDoesNotThrow { 46 | Config().maxConcurrentRequests 47 | } 48 | } 49 | 50 | @Test 51 | fun `Given timeout set in env, readTimeoutMillis returns env value`() { 52 | (env as FakeEnv)["DEVELOCITY_API_READ_TIMEOUT_MILLIS"] = "100000" 53 | assertEquals(100_000L, Config().readTimeoutMillis) 54 | } 55 | 56 | @Test 57 | fun `Given logLevel in env, logLevel is env value`() { 58 | (env as FakeEnv)["DEVELOCITY_API_LOG_LEVEL"] = "trace" 59 | assertEquals("trace", Config().logLevel) 60 | } 61 | 62 | @Test 63 | fun `Given logLevel in System props and not in env, logLevel is prop value`() { 64 | (env as FakeEnv)["DEVELOCITY_API_LOG_LEVEL"] = null 65 | (systemProperties as FakeSystemProperties).logLevel = "info" 66 | assertEquals("info", Config().logLevel) 67 | } 68 | 69 | @Test 70 | fun `Given no logLevel set, logLevel is off`() { 71 | (env as FakeEnv)["DEVELOCITY_API_LOG_LEVEL"] = null 72 | (systemProperties as FakeSystemProperties).logLevel = null 73 | assertEquals("off", Config().logLevel) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /library/src/test/kotlin/com/gabrielfeo/develocity/api/DevelocityApiExtensionsTest.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api 2 | 3 | import com.gabrielfeo.develocity.api.extension.getBuildsFlow 4 | import com.gabrielfeo.develocity.api.extension.getGradleAttributesFlow 5 | import com.gabrielfeo.develocity.api.model.FakeBuild 6 | import kotlinx.coroutines.* 7 | import kotlinx.coroutines.flow.* 8 | import kotlinx.coroutines.test.runTest 9 | import org.junit.jupiter.api.Test 10 | import kotlin.test.assertEquals 11 | import kotlin.time.Duration.Companion.seconds 12 | 13 | @OptIn(ExperimentalCoroutinesApi::class) 14 | class DevelocityApiExtensionsTest { 15 | 16 | private val api = FakeBuildsApi( 17 | builds = listOf( 18 | FakeBuild(id = "a", availableAt = 1), 19 | FakeBuild(id = "b", availableAt = 2), 20 | FakeBuild(id = "c", availableAt = 3), 21 | FakeBuild(id = "d", availableAt = 4), 22 | FakeBuild(id = "e", availableAt = 5), 23 | FakeBuild(id = "f", availableAt = 6), 24 | ) 25 | ) 26 | 27 | @Test 28 | // TODO In this case, the 2nd request could be avoided 29 | fun `Given single page, getBuildsFlow calls API twice and emits all builds`() = runTest { 30 | val builds = api.getBuildsFlow(since = 0, buildsPerPage = 1000).toList() 31 | check(api.builds.size < 1000) 32 | // On 2nd time gets empty and completes 33 | assertEquals(2, api.getBuildsCallCount.value) 34 | assertEquals(6, builds.size) 35 | } 36 | 37 | @Test 38 | fun `Given 3 pages, getBuildsFlow calls API 4 times and emits all builds`() = runTest { 39 | check(api.builds.size == 6) 40 | val builds = api.getBuildsFlow(since = 0, buildsPerPage = 2).toList() 41 | // On 4th time gets empty list and completes 42 | assertEquals(4, api.getBuildsCallCount.value) 43 | assertEquals(6, builds.size) 44 | } 45 | 46 | @Test 47 | fun `getGradleAttributesFlow calls getGradleAttributes once per build, eagerly`() = runTest { 48 | backgroundScope.launch { 49 | api.getGradleAttributesFlow(scope = this).collect { 50 | // Make the first collect never complete, simulating a slow collector 51 | Job().join() 52 | } 53 | } 54 | // Expect one eager call per build despite slow collector 55 | withTimeoutOrNull(2.seconds) { 56 | api.getGradleAttributesCallCount.take(api.builds.size).collect() 57 | } 58 | assertEquals(api.builds.size, api.getGradleAttributesCallCount.value) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /library/src/test/kotlin/com/gabrielfeo/develocity/api/DevelocityApiTest.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api 2 | 3 | import com.gabrielfeo.develocity.api.internal.* 4 | import org.junit.jupiter.api.assertDoesNotThrow 5 | import org.junit.jupiter.api.assertThrows 6 | import kotlin.test.Test 7 | import kotlin.test.assertContains 8 | 9 | class DevelocityApiTest { 10 | 11 | @Test 12 | fun `Fails eagerly if no API URL`() { 13 | env = FakeEnv() 14 | val error = assertThrows { 15 | DevelocityApi.newInstance(Config()) 16 | } 17 | error.assertRootMessageContains("DEVELOCITY_API_URL") 18 | } 19 | 20 | @Test 21 | fun `Fails lazily if no API token`() { 22 | env = FakeEnv("DEVELOCITY_API_URL" to "example-url") 23 | val api = assertDoesNotThrow { 24 | DevelocityApi.newInstance(Config()) 25 | } 26 | val error = assertThrows { 27 | api.buildsApi.toString() 28 | } 29 | error.assertRootMessageContains("DEVELOCITY_API_TOKEN") 30 | } 31 | 32 | private fun Throwable.assertRootMessageContains(text: String) { 33 | cause?.assertRootMessageContains(text) ?: assertContains(message.orEmpty(), text) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /library/src/test/kotlin/com/gabrielfeo/develocity/api/FakeBuildsApi.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api 2 | 3 | import com.gabrielfeo.develocity.api.model.* 4 | import kotlinx.coroutines.flow.MutableStateFlow 5 | import retrofit2.http.Query 6 | 7 | class FakeBuildsApi( 8 | val builds: List, 9 | ) : FakeBuildsApiScaffold { 10 | 11 | val getBuildsCallCount = MutableStateFlow(0) 12 | val getGradleAttributesCallCount = MutableStateFlow(0) 13 | 14 | override suspend fun getBuilds( 15 | fromInstant: Long?, 16 | fromBuild: String?, 17 | reverse: Boolean?, 18 | maxBuilds: Int?, 19 | maxWaitSecs: Int?, 20 | query: String?, 21 | models: List?, 22 | allModels: Boolean?, 23 | since: Long?, 24 | sinceBuild: String?, 25 | ): List { 26 | getBuildsCallCount.value++ 27 | check((reverse ?: maxWaitSecs ?: query ?: models) == null) { "Not supported" } 28 | if ((fromBuild ?: sinceBuild) != null) { 29 | check((since ?: fromInstant) == null) { "Invalid request" } 30 | } 31 | if (since != null) { 32 | check(since == 0L) { "Filtering by date is not implemented" } 33 | check((fromBuild ?: sinceBuild) == null) { "Invalid request" } 34 | } 35 | if (fromInstant != null) { 36 | check(fromInstant == 0L) { "Filtering by date is not implemented" } 37 | check((fromBuild ?: sinceBuild) == null) { "Invalid request" } 38 | } 39 | checkNotNull(maxBuilds) 40 | val first = builds.indexOfFirst { it.id == (fromBuild ?: sinceBuild) } + 1 41 | return builds 42 | .slice(first..builds.lastIndex) 43 | .take(maxBuilds) 44 | } 45 | 46 | override suspend fun getGradleAttributes( 47 | id: String, 48 | availabilityWaitTimeoutSecs: Int?, 49 | ): GradleAttributes { 50 | getGradleAttributesCallCount.value++ 51 | val attrs = readFromJsonResource("gradle-attributes-response.json") 52 | return attrs.copy(id = id) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /library/src/test/kotlin/com/gabrielfeo/develocity/api/OkHttpClientTest.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api 2 | 3 | import com.gabrielfeo.develocity.api.internal.* 4 | import com.gabrielfeo.develocity.api.internal.auth.HttpBearerAuth 5 | import com.gabrielfeo.develocity.api.internal.caching.CacheEnforcingInterceptor 6 | import com.gabrielfeo.develocity.api.internal.caching.CacheHitLoggingInterceptor 7 | import okhttp3.Dispatcher 8 | import okhttp3.OkHttpClient 9 | import kotlin.test.* 10 | 11 | class OkHttpClientTest { 12 | 13 | @Test 14 | fun `Adds authentication`() { 15 | val client = buildClient() 16 | assertTrue(client.networkInterceptors.any { it is HttpBearerAuth }) 17 | } 18 | 19 | @Test 20 | fun `Given maxConcurrentRequests, sets values in Dispatcher`() { 21 | val client = buildClient( 22 | "DEVELOCITY_API_MAX_CONCURRENT_REQUESTS" to "123" 23 | ) 24 | assertEquals(123, client.dispatcher.maxRequests) 25 | assertEquals(123, client.dispatcher.maxRequestsPerHost) 26 | } 27 | 28 | @Test 29 | fun `Given no maxConcurrentRequests, preserves original client's Dispatcher values`() { 30 | val baseClient = OkHttpClient.Builder() 31 | .dispatcher( 32 | Dispatcher().apply { 33 | maxRequests = 1 34 | maxRequestsPerHost = 1 35 | } 36 | ).build() 37 | val client = buildClient(clientBuilder = baseClient.newBuilder()) 38 | assertEquals(1, client.dispatcher.maxRequests) 39 | assertEquals(1, client.dispatcher.maxRequestsPerHost) 40 | } 41 | 42 | @Test 43 | fun `Given cache enabled, configures caching`() { 44 | val client = buildClient("DEVELOCITY_API_CACHE_ENABLED" to "true") 45 | assertTrue(client.networkInterceptors.any { it is CacheEnforcingInterceptor }) 46 | assertNotNull(client.cache) 47 | } 48 | 49 | @Test 50 | fun `Given cache disabled, no caching or cache logging`() { 51 | val client = buildClient("DEVELOCITY_API_CACHE_ENABLED" to "false") 52 | assertTrue(client.networkInterceptors.none { it is CacheEnforcingInterceptor }) 53 | assertTrue(client.interceptors.none { it is CacheHitLoggingInterceptor }) 54 | assertNull(client.cache) 55 | } 56 | 57 | @Test 58 | fun `Increases read timeout`() { 59 | val client = buildClient() 60 | val defaultTimeout = OkHttpClient.Builder().build().readTimeoutMillis 61 | assertTrue(client.readTimeoutMillis > defaultTimeout) 62 | } 63 | 64 | private fun buildClient( 65 | vararg envVars: Pair, 66 | clientBuilder: OkHttpClient.Builder? = null, 67 | ): OkHttpClient { 68 | val fakeEnv = FakeEnv(*envVars) 69 | if ("DEVELOCITY_API_TOKEN" !in fakeEnv) 70 | fakeEnv["DEVELOCITY_API_TOKEN"] = "example-token" 71 | if ("DEVELOCITY_API_URL" !in fakeEnv) 72 | fakeEnv["DEVELOCITY_API_URL"] = "example-url" 73 | env = fakeEnv 74 | val config = when (clientBuilder) { 75 | null -> Config() 76 | else -> Config(clientBuilder = clientBuilder) 77 | } 78 | return buildOkHttpClient(config, RealLoggerFactory(config)) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /library/src/test/kotlin/com/gabrielfeo/develocity/api/RetrofitTest.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api 2 | 3 | import com.gabrielfeo.develocity.api.internal.* 4 | import com.squareup.moshi.Moshi 5 | import retrofit2.Retrofit 6 | import kotlin.test.* 7 | 8 | class RetrofitTest { 9 | 10 | @Test 11 | fun `Sets instance URL from options, stripping api segment`() { 12 | val retrofit = buildRetrofit( 13 | "DEVELOCITY_API_URL" to "https://example.com/api/", 14 | ) 15 | // That's what generated classes expect 16 | assertEquals("https://example.com/", retrofit.baseUrl().toString()) 17 | } 18 | 19 | @Test 20 | fun `Rejects invalid URL`() { 21 | assertFails { 22 | buildRetrofit( 23 | "DEVELOCITY_API_URL" to "https://example.com/", 24 | ) 25 | } 26 | } 27 | 28 | private fun buildRetrofit( 29 | vararg envVars: Pair, 30 | ): Retrofit { 31 | val fakeEnv = FakeEnv(*envVars) 32 | if ("DEVELOCITY_API_TOKEN" !in fakeEnv) 33 | fakeEnv["DEVELOCITY_API_TOKEN"] = "example-token" 34 | env = fakeEnv 35 | val config = Config() 36 | return buildRetrofit( 37 | config = config, 38 | client = buildOkHttpClient(config, RealLoggerFactory(config)), 39 | moshi = Moshi.Builder().build() 40 | ) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /library/src/test/kotlin/com/gabrielfeo/develocity/api/TestResourceUtils.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api 2 | 3 | import com.gabrielfeo.develocity.api.internal.infrastructure.Serializer 4 | import com.squareup.moshi.adapter 5 | import okio.buffer 6 | import okio.source 7 | 8 | @OptIn(ExperimentalStdlibApi::class) 9 | inline fun readFromJsonResource(name: String): T { 10 | val adapter = Serializer.moshi.adapter() 11 | val classLoader = T::class.java.classLoader 12 | val jsonSource = checkNotNull(classLoader.getResourceAsStream(name)).source().buffer() 13 | val obj = adapter.fromJson(jsonSource) 14 | return requireNotNull(obj) { 15 | "JSON resource $name is null" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /library/src/test/kotlin/com/gabrielfeo/develocity/api/extension/BuildsApiExtensionsTest.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api.extension 2 | 3 | import com.gabrielfeo.develocity.api.FakeBuildsApi 4 | import com.gabrielfeo.develocity.api.model.Build 5 | import com.gabrielfeo.develocity.api.model.FakeBuild 6 | import kotlinx.coroutines.channels.Channel 7 | import kotlinx.coroutines.flow.launchIn 8 | import kotlinx.coroutines.flow.onEach 9 | import kotlinx.coroutines.test.runTest 10 | import org.junit.jupiter.api.RepeatedTest 11 | import org.junit.jupiter.api.Test 12 | import kotlin.test.assertEquals 13 | import kotlin.test.assertTrue 14 | 15 | class BuildsApiExtensionsTest { 16 | 17 | private val api = FakeBuildsApi( 18 | builds = listOf( 19 | FakeBuild(id = "a", availableAt = 1), 20 | FakeBuild(id = "b", availableAt = 2), 21 | FakeBuild(id = "c", availableAt = 3), 22 | FakeBuild(id = "d", availableAt = 4), 23 | FakeBuild(id = "e", availableAt = 5), 24 | ) 25 | ) 26 | 27 | @Test 28 | fun `getBuildsFlow with low maxBuilds pages until empty response`() = runTest { 29 | val channel = Channel(Channel.RENDEZVOUS) 30 | api.getBuildsFlow(buildsPerPage = 2) 31 | .onEach { channel.send(it) } 32 | .launchIn(this) 33 | // Collect page 1, expecting 1 request so far 34 | assertEquals(api.builds[0], channel.receive()) 35 | assertEquals(1, api.getBuildsCallCount.value) 36 | assertEquals(api.builds[1], channel.receive()) 37 | // Page 1 exhausted. Collect page 2 expecting new request 38 | assertEquals(api.builds[2], channel.receive()) 39 | assertEquals(2, api.getBuildsCallCount.value) 40 | assertEquals(api.builds[3], channel.receive()) 41 | // Page 2 exhausted. Collect page 3 expecting 2 new requests (last is empty) 42 | assertEquals(api.builds[4], channel.receive()) 43 | assertTrue(channel.tryReceive().isFailure) 44 | assertEquals(4, api.getBuildsCallCount.value) 45 | channel.close() 46 | } 47 | 48 | @Test 49 | fun `getBuildsFlow with high maxBuilds pages until empty response`() = runTest { 50 | val channel = Channel(Channel.RENDEZVOUS) 51 | api.getBuildsFlow(buildsPerPage = api.builds.size) 52 | .onEach { channel.send(it) } 53 | .launchIn(this) 54 | // Collect page 1 (with all builds), expecting 1 request so far 55 | assertEquals(api.builds[0], channel.receive()) 56 | assertEquals(1, api.getBuildsCallCount.value) 57 | assertEquals(api.builds[1], channel.receive()) 58 | assertEquals(api.builds[2], channel.receive()) 59 | assertEquals(api.builds[3], channel.receive()) 60 | assertEquals(api.builds[4], channel.receive()) 61 | // Page 1 exhausted. Expect no more builds, despite new request 62 | assertTrue(channel.tryReceive().isFailure) 63 | assertEquals(2, api.getBuildsCallCount.value) 64 | channel.close() 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /library/src/test/kotlin/com/gabrielfeo/develocity/api/extension/MappingTest.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api.extension 2 | 3 | import com.gabrielfeo.develocity.api.FakeBuildsApi 4 | import com.gabrielfeo.develocity.api.model.FakeBuild 5 | import kotlinx.coroutines.ExperimentalCoroutinesApi 6 | import kotlinx.coroutines.Job 7 | import kotlinx.coroutines.flow.asFlow 8 | import kotlinx.coroutines.flow.collect 9 | import kotlinx.coroutines.flow.take 10 | import kotlinx.coroutines.flow.toList 11 | import kotlinx.coroutines.launch 12 | import kotlinx.coroutines.test.runTest 13 | import kotlinx.coroutines.withTimeoutOrNull 14 | import org.junit.jupiter.api.Test 15 | import kotlin.test.assertEquals 16 | import kotlin.time.Duration.Companion.seconds 17 | 18 | class MappingTest { 19 | 20 | private val api = FakeBuildsApi( 21 | builds = listOf( 22 | FakeBuild(id = "a", availableAt = 1), 23 | FakeBuild(id = "b", availableAt = 2), 24 | FakeBuild(id = "c", availableAt = 3), 25 | FakeBuild(id = "d", availableAt = 4), 26 | FakeBuild(id = "e", availableAt = 5), 27 | ) 28 | ) 29 | 30 | @Test 31 | fun `withGradleAttributes pairs each build with its GradleAttributes`() = runTest { 32 | val buildsToAttrs = api.builds.asFlow().withGradleAttributes(scope = this, api).toList() 33 | assertEquals(5, api.getGradleAttributesCallCount.value) 34 | assertEquals(5, buildsToAttrs.size) 35 | buildsToAttrs.forEach { (build, attrs) -> 36 | assertEquals(build.id, attrs.id) 37 | } 38 | } 39 | 40 | @Test 41 | fun `withGradleAttributes fetches GradleAttributes for all builds eagerly`() = runTest { 42 | backgroundScope.launch { 43 | api.builds.asFlow().withGradleAttributes(scope = this, api).collect { 44 | // Make the first collect never complete, simulating a slow collector 45 | Job().join() 46 | } 47 | } 48 | // Expect 5 eager calls despite slow collector 49 | withTimeoutOrNull(2.seconds) { 50 | api.getGradleAttributesCallCount.take(5).collect() 51 | } 52 | assertEquals(5, api.getGradleAttributesCallCount.value) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /library/src/test/kotlin/com/gabrielfeo/develocity/api/internal/LoggerFactoryTest.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api.internal 2 | 3 | import kotlin.test.Test 4 | import com.gabrielfeo.develocity.api.Config 5 | import kotlin.test.AfterTest 6 | import kotlin.test.BeforeTest 7 | import kotlin.test.assertEquals 8 | 9 | class LoggerFactoryTest { 10 | 11 | private val logLevelProperty = RealLoggerFactory.LOG_LEVEL_SYSTEM_PROPERTY 12 | 13 | @BeforeTest 14 | @AfterTest 15 | fun cleanup() { 16 | System.clearProperty(logLevelProperty) 17 | env = FakeEnv("DEVELOCITY_API_URL" to "https://example.com/") 18 | } 19 | 20 | @Test 21 | fun `Level always copied from`() { 22 | val loggerFactory = RealLoggerFactory(Config(logLevel = "foo")) 23 | loggerFactory.newLogger(LoggerFactoryTest::class) 24 | assertEquals("foo", System.getProperty(logLevelProperty)) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /library/src/test/kotlin/com/gabrielfeo/develocity/api/internal/caching/CacheEnforcingInterceptorTest.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api.internal.caching 2 | 3 | import okhttp3.OkHttpClient 4 | import okhttp3.Request 5 | import okhttp3.mockwebserver.MockResponse 6 | import okhttp3.mockwebserver.MockWebServer 7 | import org.junit.jupiter.api.Test 8 | import kotlin.test.BeforeTest 9 | import kotlin.test.assertEquals 10 | import kotlin.test.assertNull 11 | 12 | private const val SHORT_TERM_CACHE_MAX_AGE = 1L 13 | private const val LONG_TERM_CACHE_MAX_AGE = 2L 14 | 15 | class CacheEnforcingInterceptorTest { 16 | 17 | private val interceptor = CacheEnforcingInterceptor( 18 | shortTermCacheUrlPattern = Regex(".*short.*"), 19 | shortTermCacheMaxAge = SHORT_TERM_CACHE_MAX_AGE, 20 | longTermCacheUrlPattern = Regex(".*long.*"), 21 | longTermCacheMaxAge = LONG_TERM_CACHE_MAX_AGE, 22 | ) 23 | 24 | private val server = MockWebServer() 25 | 26 | private val client = OkHttpClient.Builder() 27 | .addNetworkInterceptor(interceptor) 28 | .build() 29 | 30 | private val originalResponse = MockResponse() 31 | .setHeader("cache-control", "no-cache, no-store, must-revalidate") 32 | .setHeader("expires", "0") 33 | .setHeader("pragma", "no-cache") 34 | .setHeader("vary", "origin") 35 | 36 | @BeforeTest 37 | fun enqueueResponse() { 38 | server.enqueue(originalResponse) 39 | } 40 | 41 | @Test 42 | fun `URL matched for short-term cache`() { 43 | val response = client.newCall(buildRequest("/short")).execute() 44 | assertEquals("max-age=$SHORT_TERM_CACHE_MAX_AGE", response.headers["cache-control"]) 45 | assertNull(response.headers["pragma"]) 46 | assertNull(response.headers["expiry"]) 47 | assertNull(response.headers["vary"]) 48 | } 49 | 50 | @Test 51 | fun `URL matched for long-term cache`() { 52 | val response = client.newCall(buildRequest("/long")).execute() 53 | assertEquals("max-age=$LONG_TERM_CACHE_MAX_AGE", response.headers["cache-control"]) 54 | assertNull(response.headers["pragma"]) 55 | assertNull(response.headers["expiry"]) 56 | assertNull(response.headers["vary"]) 57 | } 58 | 59 | @Test 60 | fun `URL not matched for caching`() { 61 | val response = client.newCall(buildRequest("/other")).execute() 62 | assertEquals(originalResponse.headers, response.headers) 63 | } 64 | 65 | private fun buildRequest(path: String) = Request.Builder() 66 | .get() 67 | .url(server.url(path)) 68 | .build() 69 | } 70 | -------------------------------------------------------------------------------- /library/src/test/kotlin/com/gabrielfeo/develocity/api/model/BuildAttributesValueExtensionsTest.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api.model 2 | 3 | import com.gabrielfeo.develocity.api.extension.contains 4 | import com.gabrielfeo.develocity.api.extension.get 5 | import org.junit.jupiter.api.Test 6 | import kotlin.test.assertEquals 7 | import kotlin.test.assertTrue 8 | 9 | class BuildAttributesValueExtensionsTest { 10 | 11 | @Test 12 | fun get() { 13 | val list = listOf( 14 | BuildAttributesValue(name = "foo", "bar"), 15 | BuildAttributesValue(name = "bar", "foo"), 16 | ) 17 | assertEquals("bar", list["foo"]) 18 | } 19 | 20 | @Test 21 | fun contains() { 22 | val list = listOf( 23 | BuildAttributesValue(name = "foo", "bar"), 24 | ) 25 | assertTrue("foo" in list) 26 | assertTrue("bar" !in list) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /library/src/test/kotlin/com/gabrielfeo/develocity/api/model/FakeBuild.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api.model 2 | 3 | @Suppress("TestFunctionName") 4 | fun FakeBuild(id: String, availableAt: Long) = Build( 5 | id = id, 6 | availableAt = availableAt, 7 | buildToolType = "", 8 | buildToolVersion = "", 9 | buildAgentVersion = "", 10 | ) 11 | -------------------------------------------------------------------------------- /library/src/test/resources/gradle-attributes-response.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "psa5uc3o2hebc", 3 | "buildStartTime": 1655856017507, 4 | "buildDuration": 232754, 5 | "gradleVersion": "7.4.1", 6 | "pluginVersion": "3.8.1", 7 | "rootProjectName": "gradle", 8 | "requestedTasks": [], 9 | "hasFailed": false, 10 | "tags": [ 11 | "LOCAL", 12 | "Android Studio", 13 | "Dirty", 14 | "feature/example", 15 | "IDE sync", 16 | "Mac OS X" 17 | ], 18 | "values": [ 19 | { 20 | "name": "Android Studio version", 21 | "value": "2021.1.1 Patch 3" 22 | }, 23 | { 24 | "name": "Build Cache: Local", 25 | "value": "Enabled" 26 | }, 27 | { 28 | "name": "Build Cache: Remote", 29 | "value": "Enabled" 30 | }, 31 | { 32 | "name": "Build secrets migration done", 33 | "value": "true" 34 | }, 35 | { 36 | "name": "CPU: Cores", 37 | "value": "12" 38 | }, 39 | { 40 | "name": "CPU: Name", 41 | "value": "Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz" 42 | }, 43 | { 44 | "name": "CPU: Usage [Begin]", 45 | "value": "42.53% user, 23.56% sys, 33.89% idle " 46 | }, 47 | { 48 | "name": "CPU: Usage [End]", 49 | "value": "21.90% user, 19.56% sys, 58.53% idle " 50 | }, 51 | { 52 | "name": "Displays: Names", 53 | "value": "Color LCD, LG HDR WFHD" 54 | }, 55 | { 56 | "name": "Displays: Total", 57 | "value": "2" 58 | }, 59 | { 60 | "name": "Email", 61 | "value": "example@github.com" 62 | }, 63 | { 64 | "name": "Git branch", 65 | "value": "feature/example" 66 | }, 67 | { 68 | "name": "Git commit id", 69 | "value": "d92128a2124b2330f7d0f72386ee122838d383b2" 70 | }, 71 | { 72 | "name": "Git commit id short", 73 | "value": "d92128a2" 74 | }, 75 | { 76 | "name": "Git repository", 77 | "value": "https://github.com/gradle/gradle.git" 78 | }, 79 | { 80 | "name": "Gradle: StartParameter", 81 | "value": "StartParameter{taskRequests=[DefaultTaskExecutionRequest{args=[],projectPath='null',rootDir='null'}], excludedTaskNames=[], currentDir=/Users/example/projects/gradle, projectDir=/Users/example/projects/gradle, projectProperties={android.injected.build.model.only.advanced=true, android.injected.build.model.disable.src.download=true, org.gradle.kotlin.dsl.provider.cid=83559353704077, android.injected.invoked.from.ide=true, android.injected.build.model.only.versioned=3, android.injected.studio.version=2021.1.1 Patch 3, idea.gradle.do.not.build.tasks=true, android.injected.build.model.only=true}, systemPropertiesArgs={idea.active=true, java.awt.headless=true, idea.sync.active=true, org.gradle.internal.GradleProjectBuilderOptions=omit_all_tasks, idea.version=2021.1}, gradleUserHomeDir=/Users/example/.gradle, gradleHome=/Users/example/.gradle/wrapper/dists/gradle-7.4.1-all/htb4l60oox3i12rnwfoqoo8d/gradle-7.4.1, logLevel=LIFECYCLE, showStacktrace=ALWAYS, buildFile=null, initScripts=[/private/var/folders/b1/xhx7v4215k58r3ct3hf201d80000gn/T/ijmapper.gradle, /private/var/folders/b1/xhx7v4215k58r3ct3hf201d80000gn/T/sync.studio.tooling.gradle, /private/var/folders/b1/xhx7v4215k58r3ct3hf201d80000gn/T/ijinit.gradle], dryRun=false, rerunTasks=false, offline=false, refreshDependencies=false, parallelProjectExecution=true, configureOnDemand=true, maxWorkerCount=12, buildCacheEnabled=true, writeDependencyLocks=false, verificationMode=STRICT, refreshKeys=false}" 82 | }, 83 | { 84 | "name": "JVM: Available Processors", 85 | "value": "12" 86 | }, 87 | { 88 | "name": "JVM: Free Memory [Begin]", 89 | "value": "4978M" 90 | }, 91 | { 92 | "name": "JVM: Free Memory [End]", 93 | "value": "1978M" 94 | }, 95 | { 96 | "name": "JVM: Max Memory", 97 | "value": "5120M" 98 | }, 99 | { 100 | "name": "JVM: Total Memory [Begin]", 101 | "value": "5120M" 102 | }, 103 | { 104 | "name": "JVM: Total Memory [End]", 105 | "value": "5120M" 106 | }, 107 | { 108 | "name": "Memory: Swap Usage [Begin]", 109 | "value": "5679.25M" 110 | }, 111 | { 112 | "name": "Memory: Swap Usage [End]", 113 | "value": "8550.75M" 114 | }, 115 | { 116 | "name": "Memory: Total", 117 | "value": "16G" 118 | }, 119 | { 120 | "name": "Modules Count", 121 | "value": "414" 122 | }, 123 | { 124 | "name": "OS: Build", 125 | "value": "21C52" 126 | }, 127 | { 128 | "name": "OS: Name", 129 | "value": "macOS" 130 | }, 131 | { 132 | "name": "OS: Version", 133 | "value": "12.1" 134 | }, 135 | { 136 | "name": "Power: Source", 137 | "value": "AC Power" 138 | }, 139 | { 140 | "name": "Version: AGP" 141 | }, 142 | { 143 | "name": "Version: JDK", 144 | "value": "11" 145 | }, 146 | { 147 | "name": "Version: Kotlin" 148 | } 149 | ], 150 | "links": [ 151 | { 152 | "label": "Git commit id build scans", 153 | "url": "https://ge.gradle.org/scans?search.names=Git+commit+id+short&search.values=d92128a2#selection.buildScanB=psa5uc3o2hebc" 154 | } 155 | ], 156 | "gradleEnterpriseSettings": { 157 | "backgroundPublicationEnabled": true, 158 | "buildOutputCapturingEnabled": true, 159 | "taskInputsFileCapturingEnabled": true, 160 | "testOutputCapturingEnabled": true 161 | }, 162 | "develocitySettings": { 163 | "backgroundPublicationEnabled": true, 164 | "buildOutputCapturingEnabled": true, 165 | "taskInputsFileCapturingEnabled": true, 166 | "testOutputCapturingEnabled": true 167 | }, 168 | "buildOptions": { 169 | "buildCacheEnabled": true, 170 | "configurationCacheEnabled": false, 171 | "configurationOnDemandEnabled": true, 172 | "continuousBuildEnabled": false, 173 | "continueOnFailureEnabled": false, 174 | "daemonEnabled": true, 175 | "dryRunEnabled": false, 176 | "excludedTasks": [], 177 | "fileSystemWatchingEnabled": true, 178 | "maxNumberOfGradleWorkers": 12, 179 | "offlineModeEnabled": false, 180 | "parallelProjectExecutionEnabled": true, 181 | "refreshDependenciesEnabled": false, 182 | "rerunTasksEnabled": false 183 | }, 184 | "environment": { 185 | "username": "example", 186 | "operatingSystem": "macOS 12.1 (x86_64)", 187 | "numberOfCpuCores": 12, 188 | "jreVersion": "Oracle OpenJDK Runtime Environment 11.0.12+8-LTS-237", 189 | "jvmVersion": "Oracle Java HotSpot(TM) 64-Bit Server VM 11.0.12+8-LTS-237 (mixed mode)", 190 | "jvmMaxMemoryHeapSize": 5368709120, 191 | "jvmCharset": "UTF-8", 192 | "jvmLocale": "English (Brazil)", 193 | "publicHostname": "13654-MB" 194 | } 195 | } -------------------------------------------------------------------------------- /library/src/testFixtures/kotlin/com/gabrielfeo/develocity/api/FakeDevelocityApiScaffold.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api 2 | 3 | import com.gabrielfeo.develocity.api.model.* 4 | import retrofit2.http.Query 5 | 6 | /** 7 | * Scaffold for a fake `DevelocityApi` implementation with default methods throwing a 8 | * [NotImplementedError]. Extend this interface and override methods to fake behavior as needed. 9 | */ 10 | interface FakeBuildsApiScaffold : BuildsApi { 11 | 12 | override suspend fun getBuild( 13 | id: String, 14 | models: List?, 15 | allModels: Boolean?, 16 | availabilityWaitTimeoutSecs: Int?, 17 | ): Build { 18 | TODO("Not yet implemented") 19 | } 20 | 21 | override suspend fun getBazelAttributes( 22 | id: String, 23 | availabilityWaitTimeoutSecs: Int? 24 | ): BazelAttributes { 25 | TODO("Not yet implemented") 26 | } 27 | 28 | override suspend fun getBazelCriticalPath( 29 | id: String, 30 | availabilityWaitTimeoutSecs: Int? 31 | ): BazelCriticalPath { 32 | TODO("Not yet implemented") 33 | } 34 | 35 | override suspend fun getBuilds( 36 | fromInstant: Long?, 37 | fromBuild: String?, 38 | reverse: Boolean?, 39 | maxBuilds: Int?, 40 | maxWaitSecs: Int?, 41 | query: String?, 42 | models: List?, 43 | allModels: Boolean?, 44 | since: Long?, 45 | sinceBuild: String?, 46 | ): List { 47 | TODO("Not yet implemented") 48 | } 49 | 50 | override suspend fun getGradleBuildProfileOverview( 51 | id: String, 52 | availabilityWaitTimeoutSecs: Int? 53 | ): GradleBuildProfileOverview { 54 | TODO("Not yet implemented") 55 | } 56 | 57 | override suspend fun getGradleConfigurationCache( 58 | id: String, 59 | availabilityWaitTimeoutSecs: Int? 60 | ): GradleConfigurationCache { 61 | TODO("Not yet implemented") 62 | } 63 | 64 | override suspend fun getGradlePlugins( 65 | id: String, 66 | availabilityWaitTimeoutSecs: Int? 67 | ): GradlePlugins { 68 | TODO("Not yet implemented") 69 | } 70 | 71 | override suspend fun getGradleResourceUsage( 72 | id: String, 73 | availabilityWaitTimeoutSecs: Int? 74 | ): GradleResourceUsage { 75 | TODO("Not yet implemented") 76 | } 77 | 78 | override suspend fun getMavenBuildProfileOverview( 79 | id: String, 80 | availabilityWaitTimeoutSecs: Int? 81 | ): MavenBuildProfileOverview { 82 | TODO("Not yet implemented") 83 | } 84 | 85 | override suspend fun getMavenPlugins( 86 | id: String, 87 | availabilityWaitTimeoutSecs: Int? 88 | ): MavenPlugins { 89 | TODO("Not yet implemented") 90 | } 91 | 92 | override suspend fun getMavenResourceUsage( 93 | id: String, 94 | availabilityWaitTimeoutSecs: Int? 95 | ): MavenResourceUsage { 96 | TODO("Not yet implemented") 97 | } 98 | 99 | override suspend fun getGradleArtifactTransformExecutions( 100 | id: String, 101 | availabilityWaitTimeoutSecs: Int?, 102 | ): GradleArtifactTransformExecutions { 103 | TODO("Not yet implemented") 104 | } 105 | 106 | override suspend fun getGradleAttributes(id: String, availabilityWaitTimeoutSecs: Int?): GradleAttributes { 107 | TODO("Not yet implemented") 108 | } 109 | 110 | override suspend fun getGradleBuildCachePerformance( 111 | id: String, 112 | availabilityWaitTimeoutSecs: Int?, 113 | ): GradleBuildCachePerformance { 114 | TODO("Not yet implemented") 115 | } 116 | 117 | override suspend fun getGradleDeprecations(id: String, availabilityWaitTimeoutSecs: Int?): GradleDeprecations { 118 | TODO("Not yet implemented") 119 | } 120 | 121 | override suspend fun getGradleNetworkActivity( 122 | id: String, 123 | availabilityWaitTimeoutSecs: Int?, 124 | ): GradleNetworkActivity { 125 | TODO("Not yet implemented") 126 | } 127 | 128 | override suspend fun getGradleProjects(id: String, availabilityWaitTimeoutSecs: Int?): List { 129 | TODO("Not yet implemented") 130 | } 131 | 132 | override suspend fun getMavenAttributes(id: String, availabilityWaitTimeoutSecs: Int?): MavenAttributes { 133 | TODO("Not yet implemented") 134 | } 135 | 136 | override suspend fun getMavenBuildCachePerformance( 137 | id: String, 138 | availabilityWaitTimeoutSecs: Int?, 139 | ): MavenBuildCachePerformance { 140 | TODO("Not yet implemented") 141 | } 142 | 143 | override suspend fun getMavenDependencyResolution( 144 | id: String, 145 | availabilityWaitTimeoutSecs: Int?, 146 | ): MavenDependencyResolution { 147 | TODO("Not yet implemented") 148 | } 149 | 150 | override suspend fun getMavenModules(id: String, availabilityWaitTimeoutSecs: Int?): List { 151 | TODO("Not yet implemented") 152 | } 153 | 154 | override suspend fun getGradleTestPerformance( 155 | id: String, 156 | availabilityWaitTimeoutSecs: Int? 157 | ): GradleTestPerformance { 158 | TODO("Not yet implemented") 159 | } 160 | 161 | override suspend fun getMavenTestPerformance(id: String, availabilityWaitTimeoutSecs: Int?): MavenTestPerformance { 162 | TODO("Not yet implemented") 163 | } 164 | 165 | override suspend fun getNpmAttributes(id: String, availabilityWaitTimeoutSecs: Int?): NpmAttributes { 166 | TODO("Not yet implemented") 167 | } 168 | 169 | override suspend fun getPythonAttributes(id: String, availabilityWaitTimeoutSecs: Int?): PythonAttributes { 170 | TODO("Not yet implemented") 171 | } 172 | 173 | override suspend fun getGradleDependencies(id: String, availabilityWaitTimeoutSecs: Int?): GradleDependencies { 174 | TODO("Not yet implemented") 175 | } 176 | 177 | override suspend fun getMavenDependencies(id: String, availabilityWaitTimeoutSecs: Int?): MavenDependencies { 178 | TODO("Not yet implemented") 179 | } 180 | 181 | override suspend fun getSbtAttributes(id: String, availabilityWaitTimeoutSecs: Int?): SbtAttributes { 182 | TODO("Not yet implemented") 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /library/src/testFixtures/kotlin/com/gabrielfeo/develocity/api/internal/FakeEnv.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api.internal 2 | 3 | class FakeEnv( 4 | vararg vars: Pair, 5 | ) : Env { 6 | 7 | private val vars = vars.toMap(HashMap()) 8 | 9 | override fun get(name: String) = vars[name] 10 | 11 | operator fun set(name: String, value: String?) = vars.put(name, value) 12 | operator fun contains(name: String) = name in vars 13 | } 14 | -------------------------------------------------------------------------------- /library/src/testFixtures/kotlin/com/gabrielfeo/develocity/api/internal/FakeSystemProperties.kt: -------------------------------------------------------------------------------- 1 | package com.gabrielfeo.develocity.api.internal 2 | 3 | data class FakeSystemProperties( 4 | override var userHome: String? = System.getProperty("java.io.tmpdir"), 5 | override var logLevel: String? = null, 6 | ) : SystemProperties 7 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | includeBuild("./build-logic") 3 | } 4 | 5 | plugins { 6 | id("com.gradle.develocity") version("4.0") 7 | id("com.gradle.common-custom-user-data-gradle-plugin") version("2.2.1") 8 | id("org.gradle.toolchains.foojay-resolver-convention") version("0.10.0") 9 | } 10 | 11 | include( 12 | ":library", 13 | ":examples", 14 | ":examples:example-project", 15 | ) 16 | 17 | dependencyResolutionManagement { 18 | repositories { 19 | mavenCentral() 20 | } 21 | } 22 | 23 | develocity { 24 | buildScan { 25 | termsOfUseUrl = "https://gradle.com/help/legal-terms-of-use" 26 | termsOfUseAgree = "yes" 27 | capture { 28 | buildLogging = false 29 | testLogging = false 30 | } 31 | obfuscation { 32 | ipAddresses { addresses -> addresses.map { _ -> "0.0.0.0" } } 33 | hostname { "-redacted-" } 34 | } 35 | } 36 | } 37 | --------------------------------------------------------------------------------