├── .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 | [}-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 | [][14]
4 | [][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 | [][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 |
--------------------------------------------------------------------------------