imbi-plugin-sonarqube 0.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ name: Publish to PyPI
2
+ on:
3
+ release:
4
+ types: [published]
5
+
6
+ permissions:
7
+ id-token: write
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - name: Checkout repository
14
+ uses: actions/checkout@v6
15
+ with:
16
+ fetch-depth: 0
17
+
18
+ - name: Install uv
19
+ uses: astral-sh/setup-uv@v5
20
+
21
+ - name: Set up Python
22
+ run: uv python install 3.14
23
+
24
+ - name: Build
25
+ run: uv build
26
+
27
+ - name: Upload dist
28
+ uses: actions/upload-artifact@v4
29
+ with:
30
+ name: dist
31
+ path: dist/
32
+
33
+ publish:
34
+ needs: build
35
+ runs-on: ubuntu-latest
36
+ environment: pypi
37
+ steps:
38
+ - name: Download dist
39
+ uses: actions/download-artifact@v4
40
+ with:
41
+ name: dist
42
+ path: dist/
43
+
44
+ - name: Publish package
45
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,54 @@
1
+ name: Testing
2
+ on:
3
+ pull_request:
4
+ push:
5
+ branches: [ "main" ]
6
+ paths-ignore:
7
+ - 'docs/**'
8
+ - '*.md'
9
+ tags-ignore: [ "*" ]
10
+
11
+ jobs:
12
+ lint:
13
+ name: "Static Analysis"
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - name: Checkout repository
17
+ uses: actions/checkout@v5
18
+ - name: Install just
19
+ uses: extractions/setup-just@v2
20
+ with:
21
+ just-version: '1.47.1'
22
+ - name: Install uv
23
+ uses: astral-sh/setup-uv@v7
24
+ with:
25
+ enable-cache: true
26
+ - name: Run linters
27
+ run: just lint
28
+
29
+ test:
30
+ name: "Automated Tests"
31
+ runs-on: ubuntu-latest
32
+ strategy:
33
+ fail-fast: false
34
+ matrix:
35
+ python: [ "3.14" ]
36
+ steps:
37
+ - name: Checkout repository
38
+ uses: actions/checkout@v5
39
+ - name: Install just
40
+ uses: extractions/setup-just@v2
41
+ with:
42
+ just-version: '1.47.1'
43
+ - name: Install uv
44
+ uses: astral-sh/setup-uv@v7
45
+ with:
46
+ python-version: "${{ matrix.python }}"
47
+ enable-cache: true
48
+ - name: Test with python ${{ matrix.python }}
49
+ run: just test
50
+ - name: Upload Coverage
51
+ uses: codecov/codecov-action@v5
52
+ with:
53
+ files: ./build/coverage.xml
54
+ flags: unittests
@@ -0,0 +1,9 @@
1
+ .*
2
+ !/.coderabbit.yaml
3
+ !/.github
4
+ !/.gitignore
5
+ !/.pre-commit-config.yaml
6
+ !/.python-version
7
+ /build
8
+ /dist
9
+ /coverage.xml
@@ -0,0 +1,22 @@
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v6.0.0 # unrelated to the pre-commit utility version
4
+ hooks:
5
+ - id: check-executables-have-shebangs
6
+ - id: check-json
7
+ - id: check-shebang-scripts-are-executable
8
+ - id: check-toml
9
+ - id: check-yaml
10
+ - id: end-of-file-fixer
11
+ - id: mixed-line-ending
12
+ - id: trailing-whitespace
13
+ - repo: https://github.com/astral-sh/ruff-pre-commit
14
+ rev: v0.15.0
15
+ hooks:
16
+ - id: ruff-check
17
+ args: [--fix]
18
+ - id: ruff-format
19
+ - repo: https://github.com/tombi-toml/tombi-pre-commit
20
+ rev: v0.7.27
21
+ hooks:
22
+ - id: tombi-format
@@ -0,0 +1 @@
1
+ 3.14
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, AWeber
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.4
2
+ Name: imbi-plugin-sonarqube
3
+ Version: 0.0.0
4
+ Summary: SonarQube webhook-action plugin for Imbi
5
+ Author-email: Dave Shawley <daves@aweber.com>
6
+ License: BSD-3-Clause
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Natural Language :: English
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Requires-Python: >=3.14
15
+ Requires-Dist: httpx>=0.27
16
+ Requires-Dist: imbi-common~=2.5
17
+ Requires-Dist: pydantic>=2
18
+ Description-Content-Type: text/markdown
19
+
20
+ # imbi-plugin-sonarqube
21
+
22
+ SonarQube webhook-action plugin for Imbi.
23
+
24
+ The plugin registers a `sonarqube` entry in the Imbi plugin registry
25
+ (plugin type: `webhook`). When a SonarQube webhook arrives at
26
+ `imbi-gateway` and a matching `WebhookRule` dispatches to
27
+ `imbi_plugin_sonarqube.actions.update_project_from_webhook`, the
28
+ handler:
29
+
30
+ 1. Reads the metric→JSONPointer mapping from `WebhookRule.handler_config`.
31
+ 2. Fetches `/api/measures/component` from SonarQube using the plugin's
32
+ stored API token and the `ThirdPartyService.api_endpoint`.
33
+ 3. Patches the matched Imbi project's facts.
34
+
35
+ ## Configuration
36
+
37
+ Operators attach a `sonarqube` plugin instance to the SonarQube
38
+ `ThirdPartyService` and store the SonarQube API token in the plugin's
39
+ encrypted credentials.
40
+
41
+ A typical webhook rule:
42
+
43
+ ```
44
+ Handler: imbi_plugin_sonarqube.actions.update_project_from_webhook
45
+ Filter: /branch/is_main==true
46
+ Config: [
47
+ {"metric": "coverage", "path": "/test_coverage"},
48
+ {"metric": "ncloc", "path": "/lines_of_code"}
49
+ ]
50
+ ```
@@ -0,0 +1,31 @@
1
+ # imbi-plugin-sonarqube
2
+
3
+ SonarQube webhook-action plugin for Imbi.
4
+
5
+ The plugin registers a `sonarqube` entry in the Imbi plugin registry
6
+ (plugin type: `webhook`). When a SonarQube webhook arrives at
7
+ `imbi-gateway` and a matching `WebhookRule` dispatches to
8
+ `imbi_plugin_sonarqube.actions.update_project_from_webhook`, the
9
+ handler:
10
+
11
+ 1. Reads the metric→JSONPointer mapping from `WebhookRule.handler_config`.
12
+ 2. Fetches `/api/measures/component` from SonarQube using the plugin's
13
+ stored API token and the `ThirdPartyService.api_endpoint`.
14
+ 3. Patches the matched Imbi project's facts.
15
+
16
+ ## Configuration
17
+
18
+ Operators attach a `sonarqube` plugin instance to the SonarQube
19
+ `ThirdPartyService` and store the SonarQube API token in the plugin's
20
+ encrypted credentials.
21
+
22
+ A typical webhook rule:
23
+
24
+ ```
25
+ Handler: imbi_plugin_sonarqube.actions.update_project_from_webhook
26
+ Filter: /branch/is_main==true
27
+ Config: [
28
+ {"metric": "coverage", "path": "/test_coverage"},
29
+ {"metric": "ncloc", "path": "/lines_of_code"}
30
+ ]
31
+ ```
@@ -0,0 +1,62 @@
1
+ export UV_FROZEN := "1"
2
+
3
+ [default]
4
+ [private]
5
+ @help:
6
+ just --list
7
+
8
+ [private]
9
+ ci: lint test
10
+
11
+ [doc("Set up your development environment")]
12
+ [group("Environment")]
13
+ setup:
14
+ uv sync --all-groups --all-extras --frozen
15
+ uv run pre-commit install --install-hooks --overwrite
16
+
17
+ [doc("Build packages")]
18
+ [group("Development")]
19
+ build:
20
+ uv build --clear
21
+
22
+ [doc("Run tests")]
23
+ [group("Testing")]
24
+ test *ARGS: setup
25
+ #!/usr/bin/env sh
26
+ set -eu
27
+ uv run coverage run -m pytest {{ARGS}}
28
+ if [ '{{ARGS}}' = '' ]; then
29
+ uv run coverage report
30
+ uv run coverage xml
31
+ fi
32
+
33
+ [doc("Run linters")]
34
+ [group("Testing")]
35
+ lint: setup
36
+ uv run pre-commit run --all-files
37
+ uv run pyrefly check
38
+
39
+ [doc("Reformat code (optionally pass specific files)")]
40
+ [group("Development")]
41
+ format *FILES: setup
42
+ #!/usr/bin/env sh
43
+ set -eu
44
+ if [ "{{FILES}}" = '' ]; then
45
+ args='--all-files'
46
+ else
47
+ args='--files {{FILES}}'
48
+ fi
49
+ uv run pre-commit run ruff-format $args
50
+ uv run pre-commit run tombi-format $args
51
+
52
+ [doc("Remove runtime artifacts")]
53
+ [group("Environment")]
54
+ clean:
55
+ rm -f .coverage .env
56
+ rm -fR build
57
+
58
+ [confirm]
59
+ [doc("Remove caches, virtual env, and output files")]
60
+ [group("Environment")]
61
+ real-clean: clean
62
+ rm -fR .venv .*_cache dist
@@ -0,0 +1,116 @@
1
+ [project]
2
+ name = "imbi-plugin-sonarqube"
3
+ version = "0.0.0"
4
+ description = "SonarQube webhook-action plugin for Imbi"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ license = { text = "BSD-3-Clause" }
8
+ authors = [{ name = "Dave Shawley", email = "daves@aweber.com" }]
9
+ classifiers = [
10
+ "Development Status :: 4 - Beta",
11
+ "Intended Audience :: Developers",
12
+ "Natural Language :: English",
13
+ "Operating System :: OS Independent",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.14",
16
+ ]
17
+ dependencies = [
18
+ "httpx>=0.27",
19
+ "imbi-common~=2.5",
20
+ "pydantic>=2",
21
+ ]
22
+
23
+ [project.entry-points."imbi.plugins"]
24
+ sonarqube = "imbi_plugin_sonarqube.plugin:SonarqubePlugin"
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "coverage[toml]",
29
+ "imbi-common[server,databases]~=2.1",
30
+ "pre-commit",
31
+ "pyrefly~=1.0",
32
+ "pytest",
33
+ "pytest-asyncio",
34
+ "respx",
35
+ "ruff~=0.15.0",
36
+ ]
37
+
38
+ [build-system]
39
+ requires = ["hatchling"]
40
+ build-backend = "hatchling.build"
41
+
42
+ [tool.coverage.report]
43
+ exclude_also = ["typing.TYPE_CHECKING"]
44
+ fail_under = 90
45
+ show_missing = true
46
+
47
+ [tool.coverage.run]
48
+ branch = true
49
+ source = ["src/imbi_plugin_sonarqube"]
50
+
51
+ [tool.hatch.build.targets.wheel]
52
+ packages = ["src/imbi_plugin_sonarqube"]
53
+
54
+ [tool.pyrefly]
55
+ min-severity = "warn"
56
+ preset = "strict"
57
+
58
+ [tool.pyrefly.errors]
59
+ missing-override-decorator = false
60
+
61
+ [tool.pytest.ini_options]
62
+ asyncio_mode = "auto"
63
+ asyncio_default_fixture_loop_scope = "function"
64
+
65
+ [tool.ruff]
66
+ line-length = 79
67
+ target-version = "py314"
68
+
69
+ [tool.ruff.format]
70
+ quote-style = "single"
71
+
72
+ [tool.ruff.lint]
73
+ select = [
74
+ "ASYNC",
75
+ "B",
76
+ "BLE",
77
+ "C4",
78
+ "C90",
79
+ "DTZ",
80
+ "E",
81
+ "F",
82
+ "FURB",
83
+ "G",
84
+ "I",
85
+ "N",
86
+ "Q",
87
+ "RUF",
88
+ "S",
89
+ "T20",
90
+ "UP",
91
+ "W",
92
+ ]
93
+ ignore = [
94
+ "ASYNC109",
95
+ "N818",
96
+ "RSE",
97
+ "S105",
98
+ "TRY003",
99
+ "TRY400",
100
+ "UP047",
101
+ ]
102
+ flake8-quotes = { inline-quotes = "single" }
103
+
104
+ [tool.ruff.lint.mccabe]
105
+ max-complexity = 15
106
+
107
+ [tool.ruff.lint.per-file-ignores]
108
+ "tests/**/*.py" = ["S"]
109
+
110
+ [tool.uv]
111
+ constraint-dependencies = ["clickhouse-connect>=0.12rc1"]
112
+ default-groups = ["dev"]
113
+
114
+ [[tool.uv.index]]
115
+ url = "https://pypi.org/simple"
116
+ default = true
@@ -0,0 +1,6 @@
1
+ from importlib import metadata
2
+
3
+ from imbi_plugin_sonarqube.plugin import SonarqubePlugin
4
+
5
+ version = metadata.version('imbi-plugin-sonarqube')
6
+ __all__ = ['SonarqubePlugin', 'version']
@@ -0,0 +1,207 @@
1
+ """Webhook action implementations dispatched from :class:`SonarqubePlugin`."""
2
+
3
+ import logging
4
+ import typing
5
+
6
+ import httpx
7
+ import pydantic
8
+ import pydantic_settings
9
+ from imbi_common import json_pointer
10
+ from imbi_common.plugins import base as plugin_base
11
+
12
+ from imbi_plugin_sonarqube import client
13
+
14
+ LOGGER = logging.getLogger(__name__)
15
+
16
+
17
+ class _ImbiSettings(pydantic_settings.BaseSettings):
18
+ """Imbi API connection settings.
19
+
20
+ Names mirror :class:`imbi_gateway.actions.ActionSettings` so the
21
+ same operator-managed environment variables drive both the gateway
22
+ handlers and this plugin's handler.
23
+ """
24
+
25
+ model_config = {'env_prefix': 'ACTIONS_'}
26
+
27
+ imbi_url: pydantic.HttpUrl = pydantic.HttpUrl('http://imbi-api:8000')
28
+ imbi_token: str
29
+
30
+
31
+ class MetricMapping(pydantic.BaseModel):
32
+ """One row of ``WebhookRule.handler_config``.
33
+
34
+ ``metric`` is the SonarQube measure metric key
35
+ (e.g. ``'coverage'``, ``'ncloc'``). ``path`` is the JSON Pointer
36
+ target on the Imbi project that receives the metric value.
37
+ """
38
+
39
+ metric: str = pydantic.Field(
40
+ description='SonarQube measure metric key (e.g. coverage, ncloc).',
41
+ )
42
+ path: json_pointer.JsonPointer = pydantic.Field(
43
+ description=(
44
+ 'JSON Pointer on the Imbi project that receives this metric.'
45
+ ),
46
+ )
47
+
48
+
49
+ class MetricMappings(pydantic.RootModel[list[MetricMapping]]):
50
+ """``WebhookRule.handler_config`` for ``update_project_from_webhook``.
51
+
52
+ A list of metric -> JSON Pointer mappings. The action fetches each
53
+ metric from SonarQube's ``/api/measures/component`` endpoint and
54
+ patches the resolved value to the matching path on the Imbi
55
+ project.
56
+ """
57
+
58
+ root: list[MetricMapping] = pydantic.Field(
59
+ default_factory=list,
60
+ description='Ordered list of metric -> JSON Pointer mappings.',
61
+ )
62
+
63
+
64
+ def _index_measures(
65
+ response: dict[str, typing.Any],
66
+ ) -> dict[str, str]:
67
+ """Return ``{metric_name: value_str}`` from a measures response."""
68
+ component_obj: object = response.get('component') or {}
69
+ if not isinstance(component_obj, dict):
70
+ return {}
71
+ measures_obj: object = typing.cast(
72
+ 'dict[str, typing.Any]', component_obj
73
+ ).get('measures', [])
74
+ if not isinstance(measures_obj, list):
75
+ return {}
76
+ indexed: dict[str, str] = {}
77
+ for measure_obj in typing.cast('list[object]', measures_obj):
78
+ if not isinstance(measure_obj, dict):
79
+ continue
80
+ measure = typing.cast('dict[str, typing.Any]', measure_obj)
81
+ metric = measure.get('metric')
82
+ value = measure.get('value')
83
+ if isinstance(metric, str) and value is not None:
84
+ indexed[metric] = str(value)
85
+ return indexed
86
+
87
+
88
+ async def update_project_from_webhook(
89
+ *,
90
+ ctx: plugin_base.PluginContext,
91
+ credentials: dict[str, str],
92
+ external_identifier: str,
93
+ action_config: MetricMappings,
94
+ payload: object,
95
+ ) -> None:
96
+ """Patch Imbi project facts from a SonarQube webhook delivery.
97
+
98
+ The webhook ``payload`` is not consulted; the SonarQube component
99
+ key arrives via ``external_identifier`` (resolved by the gateway
100
+ from ``IMPLEMENTED_BY.identifier_selector``). Resolves the
101
+ SonarQube base URL from ``ctx.assignment_options['service_endpoint']``
102
+ (the gateway stashes the ``ThirdPartyService.api_endpoint`` there
103
+ before dispatch). Skips silently with a warning when the endpoint
104
+ or API token is missing so a misconfiguration does not 5xx the
105
+ webhook. ``action_config`` is a pre-validated :class:`MetricMappings`
106
+ instance -- the host validates the JSON blob before calling.
107
+ """
108
+ del payload
109
+ mappings = action_config.root
110
+ if not mappings:
111
+ LOGGER.debug('No metric mappings configured; nothing to do')
112
+ return
113
+
114
+ api_token = credentials.get('api_token')
115
+ if not api_token:
116
+ LOGGER.warning(
117
+ 'SonarQube api_token credential is missing; skipping project '
118
+ '%s/%s update',
119
+ ctx.org_slug,
120
+ ctx.project_id,
121
+ )
122
+ return
123
+ raw_endpoint = ctx.assignment_options.get('service_endpoint')
124
+ service_endpoint = str(raw_endpoint) if raw_endpoint else None
125
+ if not service_endpoint:
126
+ LOGGER.warning(
127
+ 'ThirdPartyService has no api_endpoint configured; skipping '
128
+ 'project %s/%s update',
129
+ ctx.org_slug,
130
+ ctx.project_id,
131
+ )
132
+ return
133
+
134
+ metric_keys = [mapping.metric for mapping in mappings]
135
+ try:
136
+ response = await client.fetch_component_measures(
137
+ base_url=service_endpoint,
138
+ api_token=api_token,
139
+ component=external_identifier,
140
+ metric_keys=metric_keys,
141
+ )
142
+ except client.SonarqubeClientError:
143
+ LOGGER.exception(
144
+ 'Failed to fetch SonarQube measures for component %r',
145
+ external_identifier,
146
+ )
147
+ return
148
+
149
+ measures = _index_measures(response)
150
+ patch: list[dict[str, typing.Any]] = []
151
+ for mapping in mappings:
152
+ value = measures.get(mapping.metric)
153
+ if value is None:
154
+ LOGGER.warning(
155
+ 'SonarQube measure %r missing for component %r; '
156
+ 'skipping patch %s',
157
+ mapping.metric,
158
+ external_identifier,
159
+ str(mapping.path),
160
+ )
161
+ continue
162
+ patch.append({'op': 'add', 'path': str(mapping.path), 'value': value})
163
+
164
+ if not patch:
165
+ LOGGER.info(
166
+ 'No SonarQube measures resolved for component %r; project %s/%s '
167
+ 'left unchanged',
168
+ external_identifier,
169
+ ctx.org_slug,
170
+ ctx.project_id,
171
+ )
172
+ return
173
+
174
+ await _patch_imbi_project(ctx.org_slug, ctx.project_id, patch)
175
+
176
+
177
+ async def _patch_imbi_project(
178
+ org_slug: str,
179
+ project_id: str,
180
+ patch: list[dict[str, typing.Any]],
181
+ ) -> None:
182
+ """PATCH the Imbi project's facts.
183
+
184
+ Reads connection settings from the ``ACTIONS_IMBI_URL`` and
185
+ ``ACTIONS_IMBI_TOKEN`` environment variables (matching the
186
+ gateway's own :class:`imbi_gateway.actions.ActionSettings`) so the
187
+ plugin requires no additional configuration.
188
+ """
189
+ settings = _ImbiSettings() # type: ignore[call-arg]
190
+ url = (
191
+ str(settings.imbi_url).rstrip('/')
192
+ + f'/organizations/{org_slug}/projects/{project_id}'
193
+ )
194
+ headers = {
195
+ 'Authorization': f'Bearer {settings.imbi_token}',
196
+ 'Content-Type': 'application/json',
197
+ }
198
+ async with httpx.AsyncClient(timeout=30.0) as http_client:
199
+ response = await http_client.patch(url, headers=headers, json=patch)
200
+ if response.is_error:
201
+ LOGGER.warning(
202
+ 'Failed to patch Imbi project %s/%s: %s %s',
203
+ org_slug,
204
+ project_id,
205
+ response.status_code,
206
+ response.text,
207
+ )