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.
- imbi_plugin_sonarqube-0.0.0/.github/workflows/publish.yml +45 -0
- imbi_plugin_sonarqube-0.0.0/.github/workflows/test.yml +54 -0
- imbi_plugin_sonarqube-0.0.0/.gitignore +9 -0
- imbi_plugin_sonarqube-0.0.0/.pre-commit-config.yaml +22 -0
- imbi_plugin_sonarqube-0.0.0/.python-version +1 -0
- imbi_plugin_sonarqube-0.0.0/LICENSE +28 -0
- imbi_plugin_sonarqube-0.0.0/PKG-INFO +50 -0
- imbi_plugin_sonarqube-0.0.0/README.md +31 -0
- imbi_plugin_sonarqube-0.0.0/justfile +62 -0
- imbi_plugin_sonarqube-0.0.0/pyproject.toml +116 -0
- imbi_plugin_sonarqube-0.0.0/src/imbi_plugin_sonarqube/__init__.py +6 -0
- imbi_plugin_sonarqube-0.0.0/src/imbi_plugin_sonarqube/actions.py +207 -0
- imbi_plugin_sonarqube-0.0.0/src/imbi_plugin_sonarqube/client.py +67 -0
- imbi_plugin_sonarqube-0.0.0/src/imbi_plugin_sonarqube/plugin.py +64 -0
- imbi_plugin_sonarqube-0.0.0/src/imbi_plugin_sonarqube/py.typed +0 -0
- imbi_plugin_sonarqube-0.0.0/tests/__init__.py +0 -0
- imbi_plugin_sonarqube-0.0.0/tests/test_actions.py +211 -0
- imbi_plugin_sonarqube-0.0.0/tests/test_client.py +97 -0
- imbi_plugin_sonarqube-0.0.0/tests/test_plugin.py +32 -0
- imbi_plugin_sonarqube-0.0.0/uv.lock +1756 -0
|
@@ -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,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,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
|
+
)
|