diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 81ff1043350..41f2b2a725b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -121,6 +121,7 @@ updates: - /backend/python/pydevlake - /backend/python/plugins/azuredevops - /backend/python/plugins/redmine + - /backend/python/plugins/zephyr - /backend/python/test/fakeplugin - /grafana/scripts schedule: diff --git a/backend/core/models/domainlayer/qa/qa_project.go b/backend/core/models/domainlayer/qa/qa_project.go index 4d2f666f746..c11b6c2bc50 100644 --- a/backend/core/models/domainlayer/qa/qa_project.go +++ b/backend/core/models/domainlayer/qa/qa_project.go @@ -19,8 +19,11 @@ package qa import ( "github.com/apache/devlake/core/models/domainlayer" + plugin "github.com/apache/devlake/core/plugin" ) +var _ plugin.Scope = (*QaProject)(nil) + // QaProject represents a QA project in the domain layer type QaProject struct { domainlayer.DomainEntityExtended @@ -30,3 +33,11 @@ type QaProject struct { func (QaProject) TableName() string { return "qa_projects" } + +func (r *QaProject) ScopeId() string { + return r.Id +} + +func (r *QaProject) ScopeName() string { + return r.Name +} diff --git a/backend/python/plugins/zephyr/README.md b/backend/python/plugins/zephyr/README.md new file mode 100644 index 00000000000..a0619020f75 --- /dev/null +++ b/backend/python/plugins/zephyr/README.md @@ -0,0 +1,50 @@ + +# Zephyr + +Apache DevLake plugin for ingesting data from [Zephyr Scale Cloud](https://smartbear.com/product/zephyr/) through the [REST API](https://support.smartbear.com/zephyr-scale-cloud/docs/en/rest-api/zephyr-scale-cloud-rest-api.html). Each DevLake scope is a Jira project. + +Test cases and executions are normalized into DevLake's QA domain. Folders, cycles, labels, and issue links stay in the tool layer for the Grafana dashboard. + +## Collected data + +| Zephyr resource | Raw table | Tool table | Domain tables | +| --- | --- | --- | --- | +| Test cases | `_raw_zephyr_testcases` | `_tool_zephyr_testcases` | `qa_test_cases` | +| Test executions | `_raw_zephyr_testexecutions` | `_tool_zephyr_testexecutions` | `qa_test_case_executions` | +| Test cycles | `_raw_zephyr_testcycles` | `_tool_zephyr_testcycles` | — | +| Folders | `_raw_zephyr_folders` | `_tool_zephyr_folders` | — | +| Test case labels | `_raw_zephyr_testcaselabels` | `_tool_zephyr_testcaselabels` | — | +| Test case issue links | `_raw_zephyr_testcaseissuelinks` | `_tool_zephyr_testcaseissuelinks` | — | +| Test execution issue links | `_raw_zephyr_testexecutionissuelinks` | `_tool_zephyr_testexecutionissuelinks` | — | +| Projects (scope) | — | `_tool_zephyr_projects` | `qa_projects` | + +Custom field values stay on `custom_fields_json`. The scope config names the custom field used as the test case type. The default name is `Test Type`. + +A full sync applies the project sync policy `timeAfter` as a created-on filter for test cases, a start filter for cycles, and an actual-end filter for executions. + +## Configuration + +1. Create a Zephyr Scale API access token in Jira under **Profile → Zephyr Scale API Access Tokens**. +2. In Config UI, add a Zephyr connection with the API base URL and that token. The US default is `https://api.zephyrscale.smartbear.com/v2`. The EU base is `https://eu.api.zephyrscale.smartbear.com/v2`. +3. Select Jira projects. Set the test type custom field name when it is not `Test Type`. + +Pass and Passed executions map to `SUCCESS`. Fail, Failed, and Blocked map to `FAILED`. In Progress maps to `IN_PROGRESS`. Any other status maps to `PENDING`. + +## Dashboard + +After a blueprint run, open the Zephyr dashboard in Grafana. MySQL and PostgreSQL definitions are in `grafana/dashboards/mysql/Zephyr.json` and `grafana/dashboards/postgresql/Zephyr.json`. diff --git a/backend/python/plugins/zephyr/build.sh b/backend/python/plugins/zephyr/build.sh new file mode 100755 index 00000000000..43613c9878a --- /dev/null +++ b/backend/python/plugins/zephyr/build.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +cd "$(dirname "$0")" +sh ../../uv.sh sync . diff --git a/backend/python/plugins/zephyr/conftest.py b/backend/python/plugins/zephyr/conftest.py new file mode 100644 index 00000000000..ea12b8f935d --- /dev/null +++ b/backend/python/plugins/zephyr/conftest.py @@ -0,0 +1,23 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import inspect + + +def pytest_pycollect_makeitem(collector, name, obj): + # Streams and models are named Test* and are imported by the real tests. + # Pytest would otherwise try to collect them and warn about their constructors. + if inspect.isclass(obj) and not str(getattr(obj, "__module__", "")).startswith("tests"): + return [] diff --git a/backend/python/plugins/zephyr/pyproject.toml b/backend/python/plugins/zephyr/pyproject.toml new file mode 100644 index 00000000000..cd3f440aeb8 --- /dev/null +++ b/backend/python/plugins/zephyr/pyproject.toml @@ -0,0 +1,38 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +[tool.poetry] +name = "zephyr" +version = "0.1.0" +description = "DevLake plugin for Zephyr Cloud" +authors = ["Roger Abelenda <3179183+rabelenda@users.noreply.github.com>"] +readme = "README.md" + +[tool.poetry.dependencies] +python = ">=3.11,<3.12" +pydevlake = { path = "../../pydevlake", develop = true } + +[tool.poetry.group.dev.dependencies] +pytest = "^9.1.1" + +[tool.pytest.ini_options] +testpaths = ["tests"] +filterwarnings = [ + "ignore:This declarative base already contains:sqlalchemy.exc.SAWarning", +] + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/backend/python/plugins/zephyr/run.sh b/backend/python/plugins/zephyr/run.sh new file mode 100755 index 00000000000..375062f9a9f --- /dev/null +++ b/backend/python/plugins/zephyr/run.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +cd "$(dirname "$0")" +sh ../../uv.sh python . zephyr/main.py "$@" diff --git a/backend/python/plugins/zephyr/tests/api_test.py b/backend/python/plugins/zephyr/tests/api_test.py new file mode 100644 index 00000000000..ebdf885b251 --- /dev/null +++ b/backend/python/plugins/zephyr/tests/api_test.py @@ -0,0 +1,58 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest.mock import MagicMock, patch + +from zephyr.api import ZephyrAPI +from zephyr.models import Connection + + +def _connection(): + return Connection( + id=1, + name='test', + endpoint='https://api.zephyrscale.smartbear.com/v2', + token='token', + ) + + +def test_iter_nextgen_paginates(): + api = ZephyrAPI(_connection()) + page1 = MagicMock() + page1.json = { + 'values': [{'id': 1}], + 'nextStartAtId': 10, + } + page2 = MagicMock() + page2.json = { + 'values': [{'id': 2}], + 'nextStartAtId': 10, + } + with patch.object(api, 'get', side_effect=[page1, page2]) as get_mock: + items = list(api._iter_nextgen('testcases/nextgen', projectKey='ABC')) + assert [item['id'] for item in items] == [1, 2] + assert get_mock.call_count == 2 + second_call = get_mock.call_args_list[1] + assert second_call.kwargs['startAtId'] == 10 + + +def test_project_lookups_index_by_id(): + api = ZephyrAPI(_connection()) + with patch.object(api, 'folders', return_value=[{'id': 5, 'name': 'Folder A'}]), patch.object( + api, 'priorities', return_value=[] + ), patch.object(api, 'statuses', return_value=[]): + lookups = api.project_lookups('ABC') + assert lookups.folders[5]['name'] == 'Folder A' + assert lookups.components == {} diff --git a/backend/python/plugins/zephyr/tests/collection_test.py b/backend/python/plugins/zephyr/tests/collection_test.py new file mode 100644 index 00000000000..99e0843c95d --- /dev/null +++ b/backend/python/plugins/zephyr/tests/collection_test.py @@ -0,0 +1,175 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +from unittest.mock import MagicMock, patch + +from pydevlake.testing import ContextBuilder + +from zephyr.main import ZephyrPlugin +from zephyr.streams.test_cases import TestCases +from zephyr.streams.test_cycles import TestCycles +from zephyr.streams.test_executions import TestExecutions + + +def _context(**kwargs): + builder = ( + ContextBuilder(ZephyrPlugin()) + .with_connection(token='token', endpoint='https://api.zephyrscale.smartbear.com/v2') + .with_scope(id='ABC', name='ABC', project_key='ABC') + .with_scope_config() + ) + if 'time_after' in kwargs: + builder = builder.with_time_after(kwargs['time_after']) + context = builder.build() + if kwargs.get('incremental'): + context.options['incremental'] = True + return context + + +def _api_mock(iter_method: str, values: list): + api = MagicMock() + api.base_url = 'https://api.zephyrscale.smartbear.com/v2' + api.project_lookups.return_value = MagicMock( + folders={}, + priorities={}, + statuses={}, + components={}, + execution_statuses={}, + cycle_statuses={}, + ) + getattr(api, iter_method).return_value = iter(values) + api.__enter__.return_value = api + api.__exit__.return_value = False + return api + + +@patch('zephyr.streams.test_cases.ZephyrAPI') +def test_test_cases_filters_by_project_time_after(ZephyrAPI): + context = _context(time_after=datetime.datetime(2024, 6, 1, tzinfo=datetime.timezone.utc)) + api = _api_mock('iter_test_cases_nextgen', [ + {'id': 1, 'createdOn': '2024-01-01T00:00:00.000Z', 'name': 'old'}, + {'id': 2, 'createdOn': '2024-07-01T00:00:00.000Z', 'name': 'new'}, + {'id': 3, 'name': 'undated'}, + ]) + ZephyrAPI.return_value = api + + collected = list(TestCases(ZephyrPlugin().name).collect({}, context)) + + assert [raw['id'] for raw, _ in collected] == [2, 3] + assert collected[0][0]['x_request_input']['createdAfter'] == '2024-06-01T00:00:00+00:00' + + +@patch('zephyr.streams.test_cases.ZephyrAPI') +def test_test_cases_incremental_recollects_all(ZephyrAPI): + context = _context( + time_after=datetime.datetime(2024, 6, 1, tzinfo=datetime.timezone.utc), + incremental=True, + ) + api = _api_mock('iter_test_cases_nextgen', [ + {'id': 1, 'createdOn': '2024-01-01T00:00:00.000Z', 'name': 'old'}, + {'id': 2, 'createdOn': '2024-07-01T00:00:00.000Z', 'name': 'new'}, + ]) + ZephyrAPI.return_value = api + + collected = list(TestCases(ZephyrPlugin().name).collect({}, context)) + + assert [raw['id'] for raw, _ in collected] == [1, 2] + assert 'createdAfter' not in collected[0][0]['x_request_input'] + + +@patch('zephyr.streams.test_cycles.ZephyrAPI') +def test_test_cycles_filters_by_project_time_after(ZephyrAPI): + context = _context(time_after=datetime.datetime(2024, 6, 1, tzinfo=datetime.timezone.utc)) + api = _api_mock('iter_test_cycles_nextgen', [ + {'id': 1, 'plannedStartDate': '2024-01-01T00:00:00.000Z', 'name': 'old'}, + {'id': 2, 'plannedStartDate': '2024-07-01T00:00:00.000Z', 'name': 'new'}, + ]) + ZephyrAPI.return_value = api + + collected = list(TestCycles(ZephyrPlugin().name).collect({}, context)) + + assert [raw['id'] for raw, _ in collected] == [2] + + +@patch('zephyr.streams.test_cycles.ZephyrAPI') +def test_test_cycles_incremental_recollects_all(ZephyrAPI): + context = _context( + time_after=datetime.datetime(2024, 6, 1, tzinfo=datetime.timezone.utc), + incremental=True, + ) + api = _api_mock('iter_test_cycles_nextgen', [ + {'id': 1, 'plannedStartDate': '2024-01-01T00:00:00.000Z', 'name': 'old'}, + {'id': 2, 'plannedStartDate': '2024-07-01T00:00:00.000Z', 'name': 'new'}, + ]) + ZephyrAPI.return_value = api + + collected = list(TestCycles(ZephyrPlugin().name).collect({}, context)) + + assert [raw['id'] for raw, _ in collected] == [1, 2] + + +@patch('zephyr.streams.test_executions.ZephyrAPI') +def test_test_executions_uses_project_time_after(ZephyrAPI): + context = _context(time_after=datetime.datetime(2024, 6, 1, tzinfo=datetime.timezone.utc)) + api = _api_mock('iter_test_executions_nextgen', [ + {'id': 1, 'actualEndDate': '2024-07-01T10:00:00.000Z'}, + ]) + ZephyrAPI.return_value = api + + _, state = next(TestExecutions(ZephyrPlugin().name).collect({}, context)) + + api.iter_test_executions_nextgen.assert_called_once_with( + 'ABC', + actual_end_date_after='2024-06-01T00:00:00Z', + ) + assert state['actual_end_date_after'] == '2024-07-01T10:00:00.000Z' + + +@patch('zephyr.streams.test_executions.ZephyrAPI') +def test_test_executions_uses_bookmark_when_incremental(ZephyrAPI): + context = _context( + time_after=datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc), + incremental=True, + ) + api = _api_mock('iter_test_executions_nextgen', [ + {'id': 1, 'actualEndDate': '2024-07-02T10:00:00.000Z'}, + ]) + ZephyrAPI.return_value = api + + _, state = next(TestExecutions(ZephyrPlugin().name).collect( + {'actual_end_date_after': '2024-07-01T00:00:00.000Z'}, + context, + )) + + api.iter_test_executions_nextgen.assert_called_once_with( + 'ABC', + actual_end_date_after='2024-07-01T00:00:00.000Z', + ) + assert state['actual_end_date_after'] == '2024-07-02T10:00:00.000Z' + + +@patch('zephyr.streams.test_executions.ZephyrAPI') +def test_test_executions_without_time_after_omits_filter(ZephyrAPI): + context = _context() + api = _api_mock('iter_test_executions_nextgen', []) + ZephyrAPI.return_value = api + + list(TestExecutions(ZephyrPlugin().name).collect({}, context)) + + api.iter_test_executions_nextgen.assert_called_once_with( + 'ABC', + actual_end_date_after=None, + ) diff --git a/backend/python/plugins/zephyr/tests/plugin_test.py b/backend/python/plugins/zephyr/tests/plugin_test.py new file mode 100644 index 00000000000..fd1ad8c913a --- /dev/null +++ b/backend/python/plugins/zephyr/tests/plugin_test.py @@ -0,0 +1,22 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pydevlake.testing import assert_valid_plugin + +from zephyr.main import ZephyrPlugin + + +def test_valid_plugin(): + assert_valid_plugin(ZephyrPlugin()) diff --git a/backend/python/plugins/zephyr/tests/streams_test.py b/backend/python/plugins/zephyr/tests/streams_test.py new file mode 100644 index 00000000000..584e7184a58 --- /dev/null +++ b/backend/python/plugins/zephyr/tests/streams_test.py @@ -0,0 +1,239 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime + +import pytest + +from pydevlake.testing import ContextBuilder + +from zephyr.main import ZephyrPlugin +from zephyr.models import TestCase, TestExecution +from zephyr.streams.test_cases import TestCases +from zephyr.streams.test_case_issue_links import TestCaseIssueLinks +from zephyr.streams.test_cycles import TestCycles +from zephyr.streams.test_execution_issue_links import TestExecutionIssueLinks +from zephyr.streams.test_executions import TestExecutions + + +def test_extract_maps_collected_test_execution_metadata(): + stream = TestExecutions(ZephyrPlugin().name) + raw = { + 'id': 200, + 'key': 'ABC-E1', + 'projectKey': 'ABC', + 'testCaseId': 100, + 'testCycleId': 50, + 'testExecutionStatusId': 7, + 'testExecutionStatusName': 'Pass', + 'jiraProjectVersionId': 3, + 'jiraProjectVersionName': 'Release 2.0', + 'actualEndDate': '2024-01-15T10:00:00.000Z', + 'executedById': 'user-1', + 'assignedToId': 'user-2', + 'issueLinksJson': '[{"id": 1, "issueId": 500, "type": "RELATED"}]', + } + execution = stream.extract(raw) + assert execution.test_case_id == 100 + assert execution.test_cycle_id == 50 + assert execution.jira_project_version_name == 'Release 2.0' + assert execution.test_execution_status_name == 'Pass' + assert execution.executed_by_id == 'user-1' + assert execution.assigned_to_id == 'user-2' + assert execution.issue_links_json is not None + assert execution.actual_end_date is not None + + +def test_enrich_preserves_flat_execution_actor_ids(): + enriched = TestExecutions._enrich_test_execution( + { + 'id': 200, + 'testCase': {'id': 100, 'key': 'ABC-T1'}, + 'testCycle': {'id': 50, 'key': 'ABC-C1', 'name': 'Sprint 1'}, + 'testExecutionStatus': {'id': 7}, + 'jiraProjectVersion': {'id': 3, 'name': 'Release 2.0'}, + 'executedById': 'user-flat-1', + 'assignedToId': 'user-flat-2', + 'links': {'issues': [{'id': 1, 'issueId': 500, 'type': 'RELATED'}]}, + 'customFields': {'Env': 'QA'}, + }, + lookups=None, + project_key='ABC', + ) + assert enriched['executedById'] == 'user-flat-1' + assert enriched['assignedToId'] == 'user-flat-2' + assert enriched['testCaseId'] == 100 + assert enriched['testCycleId'] == 50 + assert enriched['issueLinksJson'] is not None + + +def test_enrich_falls_back_to_nested_execution_actor_refs(): + enriched = TestExecutions._enrich_test_execution( + { + 'id': 200, + 'testCase': {'id': 100, 'key': 'ABC-T1'}, + 'testCycle': {'id': 50, 'key': 'ABC-C1', 'name': 'Sprint 1'}, + 'testExecutionStatus': {'id': 7}, + 'executedBy': {'accountId': 'user-1'}, + 'assignedTo': {'accountId': 'user-2'}, + }, + lookups=None, + project_key='ABC', + ) + assert enriched['executedById'] == 'user-1' + assert enriched['assignedToId'] == 'user-2' + + +def test_issue_link_substreams_require_link_id(): + plugin = ZephyrPlugin().name + case_parent = TestCase( + id=100, + key='ABC-T1', + issue_links_json='[{"issueId": 500}, {"id": 9, "issueId": 10100, "type": "COVERAGE"}]', + ) + execution_parent = TestExecution( + id=200, + key='ABC-E1', + issue_links_json='[{"issueId": 500}, {"id": 1, "issueId": 500, "type": "RELATED"}]', + ) + case_links = list(TestCaseIssueLinks(plugin).collect({}, None, case_parent)) + execution_links = list(TestExecutionIssueLinks(plugin).collect({}, None, execution_parent)) + assert [raw['id'] for raw, _ in case_links] == [9] + assert [raw['id'] for raw, _ in execution_links] == [1] + + +def test_extract_maps_collected_test_cycle_metadata(): + stream = TestCycles(ZephyrPlugin().name) + raw = { + 'id': 50, + 'key': 'ABC-C1', + 'name': 'Sprint 1 cycle', + 'description': 'Regression cycle', + 'projectKey': 'ABC', + 'folderId': 10, + 'folderName': 'Release folder', + 'statusId': 3, + 'statusName': 'Done', + 'jiraProjectVersionId': 7, + 'jiraProjectVersionName': 'Release 2.0', + 'plannedStartDate': '2024-01-01T00:00:00.000Z', + 'plannedEndDate': '2024-01-31T00:00:00.000Z', + } + cycle = stream.extract(raw) + assert cycle.key == 'ABC-C1' + assert cycle.folder_name == 'Release folder' + assert cycle.status_name == 'Done' + assert cycle.jira_project_version_name == 'Release 2.0' + assert cycle.planned_start_date is not None + assert cycle.planned_end_date is not None + + +def test_extract_maps_collected_test_case_metadata(): + stream = TestCases(ZephyrPlugin().name) + raw = { + 'id': 100, + 'key': 'ABC-T1', + 'name': 'Login test', + 'projectKey': 'ABC', + 'folderId': 10, + 'folderName': 'Regression', + 'priorityId': 2, + 'priorityName': 'High', + 'statusId': 3, + 'statusName': 'Approved', + 'componentId': 4, + 'componentName': 'Checkout', + 'labelsJson': '["Smoke"]', + 'customFieldsJson': '{"Test Type": "Manual"}', + 'issueLinksJson': '[{"id": 9, "issueId": 10100, "type": "COVERAGE"}]', + } + test_case = stream.extract(raw) + assert test_case.folder_name == 'Regression' + assert test_case.priority_name == 'High' + assert test_case.component_name == 'Checkout' + assert test_case.status_name == 'Approved' + assert test_case.labels_json is not None + assert 'Smoke' in test_case.labels_json + assert test_case.custom_fields_json is not None + + +def test_convert_uses_configured_test_type_custom_field(): + context = ( + ContextBuilder(ZephyrPlugin()) + .with_connection(token='token', endpoint='https://api.zephyrscale.smartbear.com/v2') + .with_scope(id='ABC', name='ABC', project_key='ABC') + .with_scope_config(test_type_custom_field='Test Type') + .build() + ) + stream = TestCases(ZephyrPlugin().name) + test_case = stream.extract({ + 'id': 100, + 'key': 'ABC-T1', + 'name': 'Login test', + 'projectKey': 'ABC', + 'customFieldsJson': '{"Test Type": "Manual"}', + }) + qa_test_case = next(stream.convert(test_case, context)) + assert qa_test_case.type == 'Manual' + + +def test_convert_derives_start_time_from_execution_duration(): + context = ( + ContextBuilder(ZephyrPlugin()) + .with_connection(token='token', endpoint='https://api.zephyrscale.smartbear.com/v2') + .with_scope(id='ABC', name='ABC', project_key='ABC') + .with_scope_config() + .build() + ) + finish = datetime.datetime(2026, 7, 29, 16, 55, 24, tzinfo=datetime.timezone.utc) + execution = TestExecution( + id=42, + test_case_id=100, + actual_end_date=finish, + execution_time=8000, + test_execution_status_name='Pass', + ) + domain_execution = next(TestExecutions(ZephyrPlugin().name).convert(execution, context)) + assert domain_execution.finish_time == finish + assert domain_execution.create_time == finish + assert domain_execution.start_time == finish - datetime.timedelta(milliseconds=8000) + + +@pytest.mark.parametrize( + ('status_name', 'expected_status'), + [ + ('Pass', 'SUCCESS'), + ('Fail', 'FAILED'), + ('Blocked', 'FAILED'), + ('In Progress', 'IN_PROGRESS'), + (None, 'PENDING'), + ], +) +def test_convert_maps_execution_status_to_domain(status_name, expected_status): + context = ( + ContextBuilder(ZephyrPlugin()) + .with_connection(token='token', endpoint='https://api.zephyrscale.smartbear.com/v2') + .with_scope(id='ABC', name='ABC', project_key='ABC') + .with_scope_config() + .build() + ) + execution = TestExecution( + id=42, + test_case_id=100, + test_execution_status_name=status_name, + ) + stream = TestExecutions(ZephyrPlugin().name) + domain_execution = next(stream.convert(execution, context)) + assert domain_execution.status == expected_status diff --git a/backend/python/plugins/zephyr/tests/utils_test.py b/backend/python/plugins/zephyr/tests/utils_test.py new file mode 100644 index 00000000000..e8ee3793e75 --- /dev/null +++ b/backend/python/plugins/zephyr/tests/utils_test.py @@ -0,0 +1,39 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from zephyr.utils import extract_issue_links, parse_json_list, parse_json_object + + +def test_parse_json_list_handles_empty_invalid_and_non_list(): + assert parse_json_list(None) == [] + assert parse_json_list('') == [] + assert parse_json_list('not-json') == [] + assert parse_json_list('{"a": 1}') == [] + assert parse_json_list('["a", "b"]') == ['a', 'b'] + + +def test_parse_json_object_handles_empty_invalid_and_non_object(): + assert parse_json_object(None) is None + assert parse_json_object('') is None + assert parse_json_object('not-json') is None + assert parse_json_object('["a"]') is None + assert parse_json_object('{"a": 1}') == {'a': 1} + + +def test_extract_issue_links_reads_issues_array(): + assert extract_issue_links(None) == [] + assert extract_issue_links({}) == [] + assert extract_issue_links({'issues': 'bad'}) == [] + assert extract_issue_links({'issues': [{'id': 1}]}) == [{'id': 1}] diff --git a/backend/python/plugins/zephyr/zephyr/__init__.py b/backend/python/plugins/zephyr/zephyr/__init__.py new file mode 100644 index 00000000000..d16c64f48f7 --- /dev/null +++ b/backend/python/plugins/zephyr/zephyr/__init__.py @@ -0,0 +1,15 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/backend/python/plugins/zephyr/zephyr/api.py b/backend/python/plugins/zephyr/zephyr/api.py new file mode 100644 index 00000000000..d2cd1cc06b5 --- /dev/null +++ b/backend/python/plugins/zephyr/zephyr/api.py @@ -0,0 +1,146 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Iterator, Optional + +from pydevlake.api import API, Paginator, Request, request_hook + +_PAGE_SIZE = 1000 + + +class ZephyrOffsetPaginator(Paginator): + def get_items(self, response) -> list: + return response.json.get('values') or [] + + def get_next_page_id(self, response) -> Optional[int]: + if response.json.get('isLast', True): + return None + start_at = response.json.get('startAt', 0) + max_results = response.json.get('maxResults') or len(self.get_items(response)) + return start_at + max_results + + def set_next_page_param(self, request, next_page_id): + request.query_args['startAt'] = str(next_page_id) + + +class ProjectLookups: + def __init__(self): + self.folders: dict[int, dict] = {} + self.priorities: dict[int, dict] = {} + self.statuses: dict[int, dict] = {} + self.components: dict[int, dict] = {} + self.execution_statuses: dict[int, dict] = {} + self.cycle_statuses: dict[int, dict] = {} + + +class ZephyrAPI(API): + paginator = ZephyrOffsetPaginator() + + @property + def base_url(self) -> str: + return str(self.connection.endpoint).rstrip('/') + + def __enter__(self): + return self + + def __exit__(self, *_): + pass + + @request_hook + def authenticate(self, request: Request): + request.headers['Authorization'] = ( + f'Bearer {self.connection.token.get_secret_value()}' + ) + + def test_connection(self): + self.get('projects', maxResults=1, startAt=0) + + def projects(self) -> Iterator[dict]: + yield from self.get('projects', maxResults=_PAGE_SIZE, startAt=0) + + def project_lookups(self, project_key: str) -> ProjectLookups: + lookups = ProjectLookups() + for folder in self.folders(project_key): + lookups.folders[folder['id']] = folder + for priority in self.priorities(project_key): + lookups.priorities[priority['id']] = priority + for status in self.statuses(project_key, 'TEST_CASE'): + lookups.statuses[status['id']] = status + for status in self.statuses(project_key, 'TEST_EXECUTION'): + lookups.execution_statuses[status['id']] = status + for status in self.statuses(project_key, 'TEST_CYCLE'): + lookups.cycle_statuses[status['id']] = status + # Components are Jira entities; Zephyr Scale Cloud API has no /components + # endpoint. component_id is kept from the test-case ref; name stays unset. + return lookups + + def folders(self, project_key: str, folder_type: Optional[str] = None) -> Iterator[dict]: + params = {'projectKey': project_key, 'maxResults': _PAGE_SIZE, 'startAt': 0} + if folder_type: + params['folderType'] = folder_type + yield from self.get('folders', **params) + + def priorities(self, project_key: str) -> Iterator[dict]: + yield from self.get( + 'priorities', + projectKey=project_key, + maxResults=_PAGE_SIZE, + startAt=0, + ) + + def statuses(self, project_key: str, status_type: str) -> Iterator[dict]: + yield from self.get( + 'statuses', + projectKey=project_key, + statusType=status_type, + maxResults=_PAGE_SIZE, + startAt=0, + ) + + def iter_test_cases_nextgen(self, project_key: str) -> Iterator[dict]: + yield from self._iter_nextgen('testcases/nextgen', projectKey=project_key) + + def iter_test_cycles_nextgen(self, project_key: str) -> Iterator[dict]: + yield from self._iter_nextgen('testcycles/nextgen', projectKey=project_key) + + def iter_test_executions_nextgen( + self, + project_key: str, + actual_end_date_after: Optional[str] = None, + ) -> Iterator[dict]: + params = {'projectKey': project_key, 'includeStepLinks': 'true'} + if actual_end_date_after: + params['actualEndDateAfter'] = actual_end_date_after + yield from self._iter_nextgen('testexecutions/nextgen', **params) + + def _iter_nextgen(self, path: str, **query) -> Iterator[dict]: + start_at_id = 0 + while True: + response = self.get( + path, + limit=_PAGE_SIZE, + startAtId=start_at_id, + **query, + ) + payload = response.json + values = payload.get('values') or [] + for item in values: + yield item + if not values: + return + next_start = payload.get('nextStartAtId') + if next_start is None or next_start == start_at_id: + return + start_at_id = next_start diff --git a/backend/python/plugins/zephyr/zephyr/main.py b/backend/python/plugins/zephyr/zephyr/main.py new file mode 100644 index 00000000000..2c7ad45d9a1 --- /dev/null +++ b/backend/python/plugins/zephyr/zephyr/main.py @@ -0,0 +1,106 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from zephyr.api import ZephyrAPI +from zephyr.models import Connection, Project, ScopeConfig +from zephyr.streams.folders import Folders +from zephyr.streams.test_case_issue_links import TestCaseIssueLinks +from zephyr.streams.test_case_labels import TestCaseLabels +from zephyr.streams.test_cases import TestCases +from zephyr.streams.test_cycles import TestCycles +from zephyr.streams.test_execution_issue_links import TestExecutionIssueLinks +from zephyr.streams.test_executions import TestExecutions + +import pydevlake.domain_layer.qa as qa +from pydevlake import Plugin, RemoteScopeGroup, TestConnectionResult +from pydevlake.api import APIException +from pydevlake.logger import logger +from pydevlake.model import raw_data_params + +ROOT_GROUP_ID = 'root' + + +class ZephyrPlugin(Plugin): + + @property + def connection_type(self): + return Connection + + @property + def tool_scope_type(self): + return Project + + @property + def scope_config_type(self): + return ScopeConfig + + def domain_scopes(self, project: Project): + yield qa.QaProject( + name=project.name or project.project_key or project.id, + ) + + def remote_scope_groups(self, connection) -> list[RemoteScopeGroup]: + return [RemoteScopeGroup(id=ROOT_GROUP_ID, name='Jira Projects')] + + def remote_scopes(self, connection, group_id: str) -> list[Project]: + raw_table = self._raw_scope_table_name() + with ZephyrAPI(connection) as api: + for raw in api.projects(): + if not raw.get('enabled', True): + continue + project_key = raw.get('key', '') + scope_id = project_key + project = Project( + id=scope_id, + name=project_key, + jira_project_id=raw.get('jiraProjectId'), + project_key=project_key, + ) + project.connection_id = connection.id + project.raw_data_params = raw_data_params(connection.id, scope_id) + project.raw_data_table = raw_table + yield project + + def test_connection(self, connection: Connection) -> TestConnectionResult: + with ZephyrAPI(connection) as api: + try: + api.test_connection() + return TestConnectionResult( + success=True, + message='Connection successful', + status=200, + ) + except APIException as e: + logger.warning('Zephyr test_connection failed', exc_info=True) + return TestConnectionResult(success=False, message=str(e), status=400) + except Exception as e: + logger.warning('Zephyr test_connection failed', exc_info=True) + return TestConnectionResult(success=False, message=str(e), status=400) + + @property + def streams(self): + return [ + Folders, + TestCases, + TestCaseLabels, + TestCaseIssueLinks, + TestCycles, + TestExecutions, + TestExecutionIssueLinks, + ] + + +if __name__ == '__main__': + ZephyrPlugin.start() diff --git a/backend/python/plugins/zephyr/zephyr/migrations.py b/backend/python/plugins/zephyr/zephyr/migrations.py new file mode 100644 index 00000000000..2ac06394520 --- /dev/null +++ b/backend/python/plugins/zephyr/zephyr/migrations.py @@ -0,0 +1,138 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +from typing import Optional + +from pydantic import AnyUrl, SecretStr + +from pydevlake import Field +from pydevlake.migration import migration, MigrationScriptBuilder +from pydevlake.model import Connection as BaseConnection, Model, ToolModel, ToolScope, ToolTable + + +@migration(20260724000001, name='initialize schemas for Zephyr') +def init_schemas(b: MigrationScriptBuilder): + class Connection(BaseConnection): + endpoint: AnyUrl + token: SecretStr + + class Project(ToolScope): + jira_project_id: Optional[int] + project_key: Optional[str] + + class ScopeConfig(ToolTable, Model): + name: str = Field(default='default') + entities: Optional[str] + connection_id: Optional[int] + test_type_custom_field: str = Field(default='Test Type') + + class Folder(ToolModel): + id: int = Field(primary_key=True, auto_increment=False) + project_key: Optional[str] + name: Optional[str] + parent_id: Optional[int] + folder_type: Optional[str] + index: Optional[int] + + class TestCase(ToolModel): + id: int = Field(primary_key=True, auto_increment=False) + key: Optional[str] + project_key: Optional[str] + name: Optional[str] + objective: Optional[str] + precondition: Optional[str] + created_on: Optional[datetime.datetime] + folder_id: Optional[int] + folder_name: Optional[str] + component_id: Optional[int] + component_name: Optional[str] + priority_id: Optional[int] + priority_name: Optional[str] + status_id: Optional[int] + status_name: Optional[str] + owner_account_id: Optional[str] + estimated_time: Optional[int] + labels_json: Optional[str] + custom_fields_json: Optional[str] + issue_links_json: Optional[str] + + class TestCaseLabel(ToolModel): + id: str = Field(primary_key=True, auto_increment=False) + test_case_id: Optional[int] + test_case_key: Optional[str] + label_name: Optional[str] + + class TestCaseIssueLink(ToolModel): + id: int = Field(primary_key=True, auto_increment=False) + test_case_id: Optional[int] + test_case_key: Optional[str] + issue_id: Optional[int] + link_type: Optional[str] + + class TestCycle(ToolModel): + id: int = Field(primary_key=True, auto_increment=False) + key: Optional[str] + project_key: Optional[str] + name: Optional[str] + description: Optional[str] + folder_id: Optional[int] + folder_name: Optional[str] + status_id: Optional[int] + status_name: Optional[str] + jira_project_version_id: Optional[int] + jira_project_version_name: Optional[str] + planned_start_date: Optional[datetime.datetime] + planned_end_date: Optional[datetime.datetime] + + class TestExecution(ToolModel): + id: int = Field(primary_key=True, auto_increment=False) + key: Optional[str] + project_key: Optional[str] + test_case_id: Optional[int] + test_cycle_id: Optional[int] + test_execution_status_id: Optional[int] + test_execution_status_name: Optional[str] + jira_project_version_id: Optional[int] + jira_project_version_name: Optional[str] + actual_end_date: Optional[datetime.datetime] + execution_time: Optional[int] + estimated_time: Optional[int] + executed_by_id: Optional[str] + assigned_to_id: Optional[str] + comment: Optional[str] + automated: Optional[bool] + custom_fields_json: Optional[str] + issue_links_json: Optional[str] + + class TestExecutionIssueLink(ToolModel): + id: int = Field(primary_key=True, auto_increment=False) + test_execution_id: Optional[int] + test_execution_key: Optional[str] + issue_id: Optional[int] + link_type: Optional[str] + + b.create_tables( + Connection, + Project, + ScopeConfig, + Folder, + TestCase, + TestCaseLabel, + TestCaseIssueLink, + TestCycle, + TestExecution, + TestExecutionIssueLink, + ) diff --git a/backend/python/plugins/zephyr/zephyr/models.py b/backend/python/plugins/zephyr/zephyr/models.py new file mode 100644 index 00000000000..2fbb20ff3d0 --- /dev/null +++ b/backend/python/plugins/zephyr/zephyr/models.py @@ -0,0 +1,135 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +from typing import Optional + +from pydantic import AnyUrl, SecretStr + +from pydevlake import Field, ScopeConfig as BaseScopeConfig +from pydevlake.model import Connection as BaseConnection, ToolModel, ToolScope + +from zephyr.migrations import * + +DEFAULT_API_BASE = 'https://api.zephyrscale.smartbear.com/v2' + + +class Connection(BaseConnection): + endpoint: AnyUrl = DEFAULT_API_BASE + token: SecretStr + + +class ScopeConfig(BaseScopeConfig): + test_type_custom_field: str = Field(default='Test Type', alias='testTypeCustomField') + + class Config: + allow_population_by_field_name = True + + +class Project(ToolScope, table=True): + jira_project_id: Optional[int] + project_key: Optional[str] + + +class Folder(ToolModel, table=True): + id: int = Field(primary_key=True, auto_increment=False) + project_key: Optional[str] + name: Optional[str] + parent_id: Optional[int] + folder_type: Optional[str] + index: Optional[int] + + +class TestCase(ToolModel, table=True): + id: int = Field(primary_key=True, auto_increment=False) + key: Optional[str] + project_key: Optional[str] + name: Optional[str] + objective: Optional[str] + precondition: Optional[str] + created_on: Optional[datetime.datetime] + folder_id: Optional[int] + folder_name: Optional[str] + component_id: Optional[int] + component_name: Optional[str] + priority_id: Optional[int] + priority_name: Optional[str] + status_id: Optional[int] + status_name: Optional[str] + owner_account_id: Optional[str] + estimated_time: Optional[int] + labels_json: Optional[str] + custom_fields_json: Optional[str] + issue_links_json: Optional[str] + + +class TestCaseLabel(ToolModel, table=True): + id: str = Field(primary_key=True, auto_increment=False) + test_case_id: Optional[int] + test_case_key: Optional[str] + label_name: Optional[str] + + +class TestCaseIssueLink(ToolModel, table=True): + id: int = Field(primary_key=True, auto_increment=False) + test_case_id: Optional[int] + test_case_key: Optional[str] + issue_id: Optional[int] + link_type: Optional[str] + + +class TestCycle(ToolModel, table=True): + id: int = Field(primary_key=True, auto_increment=False) + key: Optional[str] + project_key: Optional[str] + name: Optional[str] + description: Optional[str] + folder_id: Optional[int] + folder_name: Optional[str] + status_id: Optional[int] + status_name: Optional[str] + jira_project_version_id: Optional[int] + jira_project_version_name: Optional[str] + planned_start_date: Optional[datetime.datetime] + planned_end_date: Optional[datetime.datetime] + + +class TestExecution(ToolModel, table=True): + id: int = Field(primary_key=True, auto_increment=False) + key: Optional[str] + project_key: Optional[str] + test_case_id: Optional[int] + test_cycle_id: Optional[int] + test_execution_status_id: Optional[int] + test_execution_status_name: Optional[str] + jira_project_version_id: Optional[int] + jira_project_version_name: Optional[str] + actual_end_date: Optional[datetime.datetime] + execution_time: Optional[int] + estimated_time: Optional[int] + executed_by_id: Optional[str] + assigned_to_id: Optional[str] + comment: Optional[str] + automated: Optional[bool] + custom_fields_json: Optional[str] + issue_links_json: Optional[str] + + +class TestExecutionIssueLink(ToolModel, table=True): + id: int = Field(primary_key=True, auto_increment=False) + test_execution_id: Optional[int] + test_execution_key: Optional[str] + issue_id: Optional[int] + link_type: Optional[str] diff --git a/backend/python/plugins/zephyr/zephyr/streams/__init__.py b/backend/python/plugins/zephyr/zephyr/streams/__init__.py new file mode 100644 index 00000000000..d16c64f48f7 --- /dev/null +++ b/backend/python/plugins/zephyr/zephyr/streams/__init__.py @@ -0,0 +1,15 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/backend/python/plugins/zephyr/zephyr/streams/folders.py b/backend/python/plugins/zephyr/zephyr/streams/folders.py new file mode 100644 index 00000000000..78459d69564 --- /dev/null +++ b/backend/python/plugins/zephyr/zephyr/streams/folders.py @@ -0,0 +1,49 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Iterable + +from zephyr.api import ZephyrAPI +from zephyr.models import Folder, Project +from zephyr.utils import attach_x_request, scope_project_key +from pydevlake import Stream, DomainType + + +class Folders(Stream): + tool_model = Folder + domain_types = [DomainType.CODE_QUALITY] + domain_models = [] + + def collect(self, state, context) -> Iterable[tuple[object, dict]]: + project: Project = context.scope + project_key = scope_project_key(project) + with ZephyrAPI(context.connection) as api: + for raw in api.folders(project_key): + raw['projectKey'] = project_key + attach_x_request(raw, f'{api.base_url}/folders', projectKey=project_key) + yield raw, state + + def extract(self, raw: dict) -> Folder: + return Folder( + id=raw['id'], + project_key=raw.get('projectKey'), + name=raw.get('name'), + parent_id=raw.get('parentId'), + folder_type=raw.get('folderType'), + index=raw.get('index'), + ) + + def convert(self, folder: Folder, ctx): + yield from () diff --git a/backend/python/plugins/zephyr/zephyr/streams/test_case_issue_links.py b/backend/python/plugins/zephyr/zephyr/streams/test_case_issue_links.py new file mode 100644 index 00000000000..f59e203b2bb --- /dev/null +++ b/backend/python/plugins/zephyr/zephyr/streams/test_case_issue_links.py @@ -0,0 +1,53 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Iterable + +from zephyr.models import TestCase, TestCaseIssueLink +from zephyr.streams.test_cases import TestCases +from zephyr.utils import parse_json_list +from pydevlake import Substream, DomainType + + +class TestCaseIssueLinks(Substream): + tool_model = TestCaseIssueLink + domain_types = [DomainType.CODE_QUALITY] + domain_models = [] + parent_stream = TestCases + + def collect(self, state, context, parent: TestCase) -> Iterable[tuple[object, dict]]: + for link in parse_json_list(parent.issue_links_json): + link_id = link.get('id') + if link_id is None: + continue + yield { + 'id': link_id, + 'test_case_id': parent.id, + 'test_case_key': parent.key, + 'issue_id': link.get('issueId'), + 'link_type': link.get('type'), + }, state + + def extract(self, raw: dict) -> TestCaseIssueLink: + return TestCaseIssueLink( + id=raw['id'], + test_case_id=raw.get('test_case_id'), + test_case_key=raw.get('test_case_key'), + issue_id=raw.get('issue_id'), + link_type=raw.get('link_type'), + ) + + def convert(self, link: TestCaseIssueLink, ctx): + yield from () diff --git a/backend/python/plugins/zephyr/zephyr/streams/test_case_labels.py b/backend/python/plugins/zephyr/zephyr/streams/test_case_labels.py new file mode 100644 index 00000000000..37e32aba9ca --- /dev/null +++ b/backend/python/plugins/zephyr/zephyr/streams/test_case_labels.py @@ -0,0 +1,48 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Iterable + +from zephyr.models import TestCase, TestCaseLabel +from zephyr.streams.test_cases import TestCases +from zephyr.utils import parse_json_list +from pydevlake import Substream, DomainType + + +class TestCaseLabels(Substream): + tool_model = TestCaseLabel + domain_types = [DomainType.CODE_QUALITY] + domain_models = [] + parent_stream = TestCases + + def collect(self, state, context, parent: TestCase) -> Iterable[tuple[object, dict]]: + for label in parse_json_list(parent.labels_json): + yield { + 'id': f'{parent.id}:{label}', + 'test_case_id': parent.id, + 'test_case_key': parent.key, + 'label_name': label, + }, state + + def extract(self, raw: dict) -> TestCaseLabel: + return TestCaseLabel( + id=raw['id'], + test_case_id=raw.get('test_case_id'), + test_case_key=raw.get('test_case_key'), + label_name=raw.get('label_name'), + ) + + def convert(self, label: TestCaseLabel, ctx): + yield from () diff --git a/backend/python/plugins/zephyr/zephyr/streams/test_cases.py b/backend/python/plugins/zephyr/zephyr/streams/test_cases.py new file mode 100644 index 00000000000..9b2b53674bb --- /dev/null +++ b/backend/python/plugins/zephyr/zephyr/streams/test_cases.py @@ -0,0 +1,139 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +from typing import Iterable, Optional + +from zephyr.api import ProjectLookups, ZephyrAPI +from zephyr.models import Project, ScopeConfig, TestCase +from zephyr.utils import ( + parse_datetime, + extract_issue_links, + resource_id, + lookup_name, + json_dumps, + passes_time_filter, + parse_json_object, + scope_project_key, + attach_x_request, +) +import pydevlake.domain_layer.qa as qa +from pydevlake import Stream, DomainType, domain_id +from pydevlake.collection_time import collection_time_after + + +class TestCases(Stream): + tool_model = TestCase + domain_types = [DomainType.CODE_QUALITY] + domain_models = [qa.QaTestCase] + + def collect(self, state, context) -> Iterable[tuple[object, dict]]: + project: Project = context.scope + project_key = scope_project_key(project) + run_state = dict(state) + # API has no date filter or updatedOn; apply project time frame client-side on + # full sync only. Incremental recollects all so updates to existing cases are kept. + created_after = None if context.incremental else collection_time_after(context) + with ZephyrAPI(context.connection) as api: + lookups = api.project_lookups(project_key) + for raw in api.iter_test_cases_nextgen(project_key): + if not passes_time_filter(raw.get('createdOn'), created_after): + continue + enriched = self._enrich_test_case(raw, lookups, project_key) + request_input = {'projectKey': project_key} + if created_after: + request_input['createdAfter'] = created_after.isoformat() + attach_x_request(enriched, f'{api.base_url}/testcases/nextgen', **request_input) + yield enriched, run_state + + def extract(self, raw: dict) -> TestCase: + return TestCase( + id=raw['id'], + key=raw.get('key'), + project_key=raw.get('projectKey'), + name=raw.get('name'), + objective=raw.get('objective'), + precondition=raw.get('precondition'), + created_on=parse_datetime(raw.get('createdOn')), + folder_id=raw.get('folderId'), + folder_name=raw.get('folderName'), + component_id=raw.get('componentId'), + component_name=raw.get('componentName'), + priority_id=raw.get('priorityId'), + priority_name=raw.get('priorityName'), + status_id=raw.get('statusId'), + status_name=raw.get('statusName'), + owner_account_id=raw.get('ownerAccountId'), + estimated_time=raw.get('estimatedTime'), + labels_json=raw.get('labelsJson'), + custom_fields_json=raw.get('customFieldsJson'), + issue_links_json=raw.get('issueLinksJson'), + ) + + def convert(self, test_case: TestCase, ctx) -> Iterable[qa.QaTestCase]: + scope_config: ScopeConfig = ctx.scope_config + custom_fields = parse_json_object(test_case.custom_fields_json) + test_type = self._custom_field_value( + custom_fields, + scope_config.test_type_custom_field, + ) + yield qa.QaTestCase( + id=domain_id(TestCase, ctx.connection.id, test_case.id), + name=test_case.name or test_case.key or '', + type=test_type or 'functional', + create_time=test_case.created_on, + creator_id=test_case.owner_account_id, + qa_api_id=test_case.key, + qa_project_id=ctx.scope.domain_id(), + ) + + @staticmethod + def _custom_field_value(custom_fields: Optional[dict], field_name: str) -> Optional[str]: + if not custom_fields or not field_name: + return None + value = custom_fields.get(field_name) + if value is None: + return None + elif isinstance(value, (list, dict)): + return json.dumps(value) + else: + return str(value) + + @staticmethod + def _enrich_test_case(raw: dict, lookups: ProjectLookups, project_key: str) -> dict: + folder = raw.get('folder') + component = raw.get('component') + priority = raw.get('priority') + status = raw.get('status') + owner = raw.get('owner') or {} + custom_fields = raw.get('customFields') + labels = raw.get('labels') or [] + links = raw.get('links') + return { + **raw, + 'projectKey': project_key, + 'folderId': resource_id(folder), + 'folderName': lookup_name(lookups, 'folders', folder), + 'componentId': resource_id(component), + 'componentName': lookup_name(lookups, 'components', component), + 'priorityId': resource_id(priority), + 'priorityName': lookup_name(lookups, 'priorities', priority), + 'statusId': resource_id(status), + 'statusName': lookup_name(lookups, 'statuses', status), + 'ownerAccountId': owner.get('accountId'), + 'labelsJson': json_dumps(labels), + 'customFieldsJson': json_dumps(custom_fields), + 'issueLinksJson': json_dumps(extract_issue_links(links)), + } diff --git a/backend/python/plugins/zephyr/zephyr/streams/test_cycles.py b/backend/python/plugins/zephyr/zephyr/streams/test_cycles.py new file mode 100644 index 00000000000..310cbc51798 --- /dev/null +++ b/backend/python/plugins/zephyr/zephyr/streams/test_cycles.py @@ -0,0 +1,90 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Iterable + +from zephyr.api import ProjectLookups, ZephyrAPI +from zephyr.models import Project, TestCycle +from zephyr.utils import ( + parse_datetime, + resource_id, + lookup_name, + passes_time_filter, + scope_project_key, + attach_x_request, +) +from pydevlake import Stream, DomainType +from pydevlake.collection_time import collection_time_after + + +class TestCycles(Stream): + tool_model = TestCycle + domain_types = [DomainType.CODE_QUALITY] + domain_models = [] + + def collect(self, state, context) -> Iterable[tuple[object, dict]]: + project: Project = context.scope + project_key = scope_project_key(project) + run_state = dict(state) + # API has no date filter; apply project time frame client-side on full sync only. + # Incremental recollects all so updates to existing cycles are kept. + started_after = None if context.incremental else collection_time_after(context) + with ZephyrAPI(context.connection) as api: + lookups = api.project_lookups(project_key) + for raw in api.iter_test_cycles_nextgen(project_key): + if not passes_time_filter(raw.get('plannedStartDate'), started_after): + continue + enriched = self._enrich_test_cycle(raw, lookups, project_key) + request_input = {'projectKey': project_key} + if started_after: + request_input['plannedStartAfter'] = started_after.isoformat() + attach_x_request(enriched, f'{api.base_url}/testcycles/nextgen', **request_input) + yield enriched, run_state + + def extract(self, raw: dict) -> TestCycle: + return TestCycle( + id=raw['id'], + key=raw.get('key'), + project_key=raw.get('projectKey'), + name=raw.get('name'), + description=raw.get('description'), + folder_id=raw.get('folderId'), + folder_name=raw.get('folderName'), + status_id=raw.get('statusId'), + status_name=raw.get('statusName'), + jira_project_version_id=raw.get('jiraProjectVersionId'), + jira_project_version_name=raw.get('jiraProjectVersionName'), + planned_start_date=parse_datetime(raw.get('plannedStartDate')), + planned_end_date=parse_datetime(raw.get('plannedEndDate')), + ) + + def convert(self, cycle: TestCycle, ctx): + yield from () + + @staticmethod + def _enrich_test_cycle(raw: dict, lookups: ProjectLookups, project_key: str) -> dict: + folder = raw.get('folder') + status = raw.get('status') + version = raw.get('jiraProjectVersion') + return { + **raw, + 'projectKey': project_key, + 'folderId': resource_id(folder), + 'folderName': lookup_name(lookups, 'folders', folder), + 'statusId': resource_id(status), + 'statusName': lookup_name(lookups, 'cycle_statuses', status), + 'jiraProjectVersionId': resource_id(version), + 'jiraProjectVersionName': version.get('name') if version else None, + } diff --git a/backend/python/plugins/zephyr/zephyr/streams/test_execution_issue_links.py b/backend/python/plugins/zephyr/zephyr/streams/test_execution_issue_links.py new file mode 100644 index 00000000000..49edadf24ea --- /dev/null +++ b/backend/python/plugins/zephyr/zephyr/streams/test_execution_issue_links.py @@ -0,0 +1,53 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Iterable + +from zephyr.models import TestExecution, TestExecutionIssueLink +from zephyr.streams.test_executions import TestExecutions +from zephyr.utils import parse_json_list +from pydevlake import Substream, DomainType + + +class TestExecutionIssueLinks(Substream): + tool_model = TestExecutionIssueLink + domain_types = [DomainType.CODE_QUALITY] + domain_models = [] + parent_stream = TestExecutions + + def collect(self, state, context, parent: TestExecution) -> Iterable[tuple[object, dict]]: + for link in parse_json_list(parent.issue_links_json): + link_id = link.get('id') + if link_id is None: + continue + yield { + 'id': link_id, + 'test_execution_id': parent.id, + 'test_execution_key': parent.key, + 'issue_id': link.get('issueId'), + 'link_type': link.get('type'), + }, state + + def extract(self, raw: dict) -> TestExecutionIssueLink: + return TestExecutionIssueLink( + id=raw['id'], + test_execution_id=raw.get('test_execution_id'), + test_execution_key=raw.get('test_execution_key'), + issue_id=raw.get('issue_id'), + link_type=raw.get('link_type'), + ) + + def convert(self, link: TestExecutionIssueLink, ctx): + yield from () diff --git a/backend/python/plugins/zephyr/zephyr/streams/test_executions.py b/backend/python/plugins/zephyr/zephyr/streams/test_executions.py new file mode 100644 index 00000000000..c6adfb98237 --- /dev/null +++ b/backend/python/plugins/zephyr/zephyr/streams/test_executions.py @@ -0,0 +1,161 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Iterable, Optional +import datetime + +from zephyr.api import ProjectLookups, ZephyrAPI +from zephyr.models import Project, TestCase, TestExecution +from zephyr.utils import ( + parse_datetime, + resource_id, + lookup_name, + json_dumps, + extract_issue_links, + scope_project_key, + attach_x_request, +) +import pydevlake.domain_layer.qa as qa +from pydevlake import Stream, DomainType, domain_id +from pydevlake.collection_time import collection_time_after, format_rfc3339_utc + + +def _track_max_date(state: dict, key: str, value: Optional[str]) -> None: + if not value: + return + current = state.get(key) + if current is None or value > current: + state[key] = value + + +def _execution_start_time( + finish: Optional[datetime.datetime], + execution_time_ms: Optional[int], +) -> Optional[datetime.datetime]: + if finish is None: + return None + if not execution_time_ms or execution_time_ms < 0: + return finish + return finish - datetime.timedelta(milliseconds=execution_time_ms) + + +class TestExecutions(Stream): + tool_model = TestExecution + domain_types = [DomainType.CODE_QUALITY] + domain_models = [qa.QaTestCaseExecution] + + def collect(self, state, context) -> Iterable[tuple[object, dict]]: + project: Project = context.scope + project_key = scope_project_key(project) + run_state = dict(state) + actual_end_after = None + if context.incremental: + actual_end_after = run_state.get('actual_end_date_after') + else: + time_after = collection_time_after(context) + if time_after: + actual_end_after = format_rfc3339_utc(time_after) + + with ZephyrAPI(context.connection) as api: + lookups = api.project_lookups(project_key) + for raw in api.iter_test_executions_nextgen( + project_key, + actual_end_date_after=actual_end_after, + ): + enriched = self._enrich_test_execution(raw, lookups, project_key) + _track_max_date(run_state, 'actual_end_date_after', enriched.get('actualEndDate')) + attach_x_request( + enriched, + f'{api.base_url}/testexecutions/nextgen', + projectKey=project_key, + actualEndDateAfter=actual_end_after, + ) + yield enriched, run_state + + def extract(self, raw: dict) -> TestExecution: + return TestExecution( + id=raw['id'], + key=raw.get('key'), + project_key=raw.get('projectKey'), + test_case_id=raw.get('testCaseId'), + test_cycle_id=raw.get('testCycleId'), + test_execution_status_id=raw.get('testExecutionStatusId'), + test_execution_status_name=raw.get('testExecutionStatusName'), + jira_project_version_id=raw.get('jiraProjectVersionId'), + jira_project_version_name=raw.get('jiraProjectVersionName'), + actual_end_date=parse_datetime(raw.get('actualEndDate')), + execution_time=raw.get('executionTime'), + estimated_time=raw.get('estimatedTime'), + executed_by_id=raw.get('executedById'), + assigned_to_id=raw.get('assignedToId'), + comment=raw.get('comment'), + automated=raw.get('automated'), + custom_fields_json=raw.get('customFieldsJson'), + issue_links_json=raw.get('issueLinksJson'), + ) + + def convert(self, execution: TestExecution, ctx) -> Iterable[qa.QaTestCaseExecution]: + if not execution.test_case_id: + return + test_case_id = domain_id(TestCase, ctx.connection.id, execution.test_case_id) + finish_time = execution.actual_end_date + yield qa.QaTestCaseExecution( + id=domain_id(TestExecution, ctx.connection.id, execution.id), + qa_project_id=ctx.scope.domain_id(), + qa_test_case_id=test_case_id, + create_time=finish_time, + start_time=_execution_start_time(finish_time, execution.execution_time), + finish_time=finish_time, + creator_id=execution.executed_by_id, + status=self._map_execution_status(execution.test_execution_status_name), + ) + + @staticmethod + def _map_execution_status(status_name: Optional[str]) -> str: + if not status_name: + return 'PENDING' + normalized = status_name.strip().lower() + if normalized in ('pass', 'passed'): + return 'SUCCESS' + elif normalized in ('fail', 'failed', 'blocked'): + return 'FAILED' + elif normalized in ('in progress', 'in_progress', 'executing'): + return 'IN_PROGRESS' + else: + return 'PENDING' + + @staticmethod + def _enrich_test_execution(raw: dict, lookups: ProjectLookups, project_key: str) -> dict: + test_case = raw.get('testCase') or {} + test_cycle = raw.get('testCycle') or {} + status = raw.get('testExecutionStatus') or {} + version = raw.get('jiraProjectVersion') or {} + links = raw.get('links') + custom_fields = raw.get('customFields') + return { + **raw, + 'projectKey': project_key, + 'testCaseId': resource_id(test_case), + 'testCycleId': resource_id(test_cycle), + 'testExecutionStatusId': resource_id(status), + 'testExecutionStatusName': lookup_name(lookups, 'execution_statuses', status), + 'jiraProjectVersionId': resource_id(version), + 'jiraProjectVersionName': version.get('name') if version else None, + # nextgen returns flat ids; fall back to nested user refs if present + 'executedById': raw.get('executedById') or (raw.get('executedBy') or {}).get('accountId'), + 'assignedToId': raw.get('assignedToId') or (raw.get('assignedTo') or {}).get('accountId'), + 'customFieldsJson': json_dumps(custom_fields), + 'issueLinksJson': json_dumps(extract_issue_links(links)), + } diff --git a/backend/python/plugins/zephyr/zephyr/utils.py b/backend/python/plugins/zephyr/zephyr/utils.py new file mode 100644 index 00000000000..284280f9d14 --- /dev/null +++ b/backend/python/plugins/zephyr/zephyr/utils.py @@ -0,0 +1,121 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import json +import re +from typing import Any, Optional + +from zephyr.api import ProjectLookups + +_RESOURCE_KEY_RE = re.compile(r'/([^/]+)$') + + +def parse_datetime(value) -> Optional[datetime.datetime]: + if value is None: + return None + elif isinstance(value, datetime.datetime): + return value + elif isinstance(value, str) and value: + return datetime.datetime.fromisoformat(value.replace('Z', '+00:00')) + else: + return None + + +def _ensure_utc(value: datetime.datetime) -> datetime.datetime: + if value.tzinfo is None: + return value.replace(tzinfo=datetime.timezone.utc) + return value.astimezone(datetime.timezone.utc) + + +def is_on_or_after(value, cutoff: datetime.datetime) -> bool: + parsed = parse_datetime(value) + if parsed is None: + return True + return _ensure_utc(parsed) >= _ensure_utc(cutoff) + + +def passes_time_filter(value, cutoff: Optional[datetime.datetime]) -> bool: + return cutoff is None or is_on_or_after(value, cutoff) + + +def scope_project_key(project) -> str: + return project.project_key or project.id + + +def attach_x_request(raw: dict, url: str, **input) -> None: + raw['x_request_url'] = url + raw['x_request_input'] = input + + +def resource_id(ref: Optional[dict]) -> Optional[int]: + if not ref: + return None + ref_id = ref.get('id') + return int(ref_id) if ref_id is not None else None + + +def resource_key(ref: Optional[dict]) -> Optional[str]: + if not ref: + return None + if ref.get('key'): + return ref['key'] + self_url = ref.get('self') or '' + match = _RESOURCE_KEY_RE.search(self_url) + return match.group(1) if match else None + + +def lookup_name(lookups: Optional[ProjectLookups], table: str, ref: Optional[dict]) -> Optional[str]: + ref_id = resource_id(ref) + if ref_id is None: + return ref.get('name') if ref else None + if not lookups: + return ref.get('name') if ref else None + bucket = getattr(lookups, table, {}) + entry = bucket.get(ref_id) + if entry: + return entry.get('name') + return ref.get('name') if ref else None + + +def json_dumps(value: Any) -> Optional[str]: + return json.dumps(value) if value is not None else None + + +def parse_json_list(value: Optional[str]) -> list: + if not value: + return [] + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, list) else [] + except json.JSONDecodeError: + return [] + + +def parse_json_object(value: Optional[str]) -> Optional[dict]: + if not value: + return None + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else None + except json.JSONDecodeError: + return None + + +def extract_issue_links(links: Optional[dict]) -> list[dict]: + if not links: + return [] + issues = links.get('issues') or [] + return issues if isinstance(issues, list) else [] diff --git a/backend/python/pydevlake/pydevlake/domain_layer/qa.py b/backend/python/pydevlake/pydevlake/domain_layer/qa.py new file mode 100644 index 00000000000..481fbb52542 --- /dev/null +++ b/backend/python/pydevlake/pydevlake/domain_layer/qa.py @@ -0,0 +1,47 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import datetime +from typing import Optional + +from sqlmodel import Field + +from pydevlake.model import DomainModel, DomainScope + + +class QaProject(DomainScope, table=True): + __tablename__ = 'qa_projects' + name: str + + +class QaTestCase(DomainModel, table=True): + __tablename__ = 'qa_test_cases' + name: str + create_time: Optional[datetime] + creator_id: Optional[str] + type: Optional[str] + qa_api_id: Optional[str] + qa_project_id: str + + +class QaTestCaseExecution(DomainModel, table=True): + __tablename__ = 'qa_test_case_executions' + qa_project_id: str + qa_test_case_id: str + create_time: Optional[datetime] + start_time: Optional[datetime] + finish_time: Optional[datetime] + creator_id: Optional[str] + status: Optional[str] + is_invalid: bool = False diff --git a/backend/server/services/remote/models/dynamic_domain_scopes.go b/backend/server/services/remote/models/dynamic_domain_scopes.go index 848d5f1880a..dc1b5dcfd67 100644 --- a/backend/server/services/remote/models/dynamic_domain_scopes.go +++ b/backend/server/services/remote/models/dynamic_domain_scopes.go @@ -25,6 +25,7 @@ import ( "github.com/apache/devlake/core/models/domainlayer/code" "github.com/apache/devlake/core/models/domainlayer/codequality" "github.com/apache/devlake/core/models/domainlayer/devops" + "github.com/apache/devlake/core/models/domainlayer/qa" "github.com/apache/devlake/core/models/domainlayer/ticket" "github.com/apache/devlake/core/plugin" ) @@ -39,6 +40,8 @@ func newScopeByTypeName(typeName string) (plugin.Scope, errors.Error) { return &devops.CicdScope{}, nil case "Board": return &ticket.Board{}, nil + case "QaProject": + return &qa.QaProject{}, nil default: return nil, errors.BadInput.New(fmt.Sprintf("Unknown scope type %s", typeName)) } diff --git a/config-ui/src/plugins/components/scope-config-form/index.tsx b/config-ui/src/plugins/components/scope-config-form/index.tsx index c692ef6f973..74f00cd1473 100644 --- a/config-ui/src/plugins/components/scope-config-form/index.tsx +++ b/config-ui/src/plugins/components/scope-config-form/index.tsx @@ -40,6 +40,7 @@ import { AsanaTransformation } from '@/plugins/register/asana'; import { ClickUpTransformation } from '@/plugins/register/clickup'; import { YoutrackTransformation } from '@/plugins/register/youtrack'; import { RedmineTransformation } from '@/plugins/register/redmine'; +import { ZephyrTransformation } from '@/plugins/register/zephyr'; import { DOC_URL } from '@/release'; import { operator } from '@/utils'; @@ -346,6 +347,14 @@ export const ScopeConfigForm = ({ setTransformation={setTransformation} /> )} + + {plugin === 'zephyr' && ( + + )} diff --git a/config-ui/src/plugins/register/index.ts b/config-ui/src/plugins/register/index.ts index 56c884ad0a4..8a1b59b1494 100644 --- a/config-ui/src/plugins/register/index.ts +++ b/config-ui/src/plugins/register/index.ts @@ -45,6 +45,7 @@ import { TAPDConfig } from './tapd'; import { WebhookConfig } from './webhook'; import { YoutrackConfig } from './youtrack'; import { ZenTaoConfig } from './zentao'; +import { ZephyrConfig } from './zephyr'; import { OpsgenieConfig } from './opsgenie'; import { TeambitionConfig } from './teambition'; import { TestmoConfig } from './testmo'; @@ -81,6 +82,7 @@ export const pluginConfigs: IPluginConfig[] = [ TAPDConfig, TestmoConfig, ZenTaoConfig, + ZephyrConfig, WebhookConfig, YoutrackConfig, OpsgenieConfig, diff --git a/config-ui/src/plugins/register/zephyr/assets/icon.svg b/config-ui/src/plugins/register/zephyr/assets/icon.svg new file mode 100644 index 00000000000..a020e9bf71e --- /dev/null +++ b/config-ui/src/plugins/register/zephyr/assets/icon.svg @@ -0,0 +1,22 @@ + + + + + + + diff --git a/config-ui/src/plugins/register/zephyr/config.tsx b/config-ui/src/plugins/register/zephyr/config.tsx new file mode 100644 index 00000000000..3047a09bc7a --- /dev/null +++ b/config-ui/src/plugins/register/zephyr/config.tsx @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { IPluginConfig } from '@/types'; + +import Icon from './assets/icon.svg?react'; +import { ZephyrDataScope } from './data-scope'; + +export const ZephyrConfig: IPluginConfig = { + plugin: 'zephyr', + name: 'Zephyr', + icon: ({ color }) => , + sort: 17.5, + isBeta: true, + connection: { + docLink: + 'https://support.smartbear.com/zephyr-scale-cloud/docs/en/rest-api/generating-api-access-tokens.html', + initialValues: { + endpoint: 'https://api.zephyrscale.smartbear.com/v2', + }, + fields: [ + 'name', + { + key: 'endpoint', + subLabel: + 'Zephyr Scale Cloud API base URL. Default (US): https://api.zephyrscale.smartbear.com/v2. EU: https://eu.api.zephyrscale.smartbear.com/v2', + }, + { + key: 'token', + label: 'API Access Token', + type: 'password', + subLabel: 'From Jira: Profile → Zephyr Scale API Access Tokens', + }, + 'proxy', + ], + }, + dataScope: { + title: 'Jira Projects', + render: ({ connectionId, disabledItems, selectedItems, onChangeSelectedItems }) => ( + + ), + }, + scopeConfig: { + entities: ['CODEQUALITY'], + transformation: { + testTypeCustomField: 'Test Type', + }, + }, +}; diff --git a/config-ui/src/plugins/register/zephyr/data-scope.tsx b/config-ui/src/plugins/register/zephyr/data-scope.tsx new file mode 100644 index 00000000000..5df4e7d42f1 --- /dev/null +++ b/config-ui/src/plugins/register/zephyr/data-scope.tsx @@ -0,0 +1,175 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { useEffect, useMemo, useState } from 'react'; +import { SearchOutlined } from '@ant-design/icons'; +import { Input, Space, Tag, message } from 'antd'; + +import API from '@/api'; +import { Block, Loading } from '@/components'; + +const ROOT_GROUP_ID = 'root'; + +interface ProjectScopeData { + id: string; + name?: string; + projectKey?: string; +} + +interface ScopeItem { + id: string; + name: string; + data: ProjectScopeData; +} + +interface Props { + connectionId: ID; + disabledItems?: Array<{ id: ID }>; + selectedItems: ScopeItem[]; + onChangeSelectedItems: (items: ScopeItem[]) => void; +} + +export const ZephyrDataScope = ({ + connectionId, + disabledItems = [], + selectedItems, + onChangeSelectedItems, +}: Props) => { + const [loading, setLoading] = useState(true); + const [items, setItems] = useState([]); + const [query, setQuery] = useState(''); + + const disabledIds = useMemo(() => new Set(disabledItems.map((it) => `${it.id}`)), [disabledItems]); + + useEffect(() => { + let cancelled = false; + + const loadProjects = async () => { + setLoading(true); + try { + const res = await API.scope.remote('zephyr', connectionId, { groupId: ROOT_GROUP_ID }); + if (cancelled) { + return; + } + const scopes = (res.children ?? []) + .filter((it) => it.type === 'scope') + .map((it) => ({ + id: `${it.id}`, + name: it.name, + data: it.data as ProjectScopeData, + })); + setItems(scopes); + } catch (err: any) { + if (!cancelled) { + message.error(err?.response?.data?.message ?? 'Failed to load Zephyr projects'); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + loadProjects(); + + return () => { + cancelled = true; + }; + }, [connectionId]); + + const filteredItems = useMemo(() => { + const term = query.trim().toLowerCase(); + if (!term) { + return items; + } + return items.filter((item) => { + const projectKey = item.data?.projectKey ?? ''; + return item.name.toLowerCase().includes(term) || projectKey.toLowerCase().includes(term); + }); + }, [items, query]); + + return ( + <> + + + {selectedItems.length ? ( + selectedItems.map((item) => ( + onChangeSelectedItems(selectedItems.filter((it) => it.id !== item.id))} + > + {item.name || item.id} + + )) + ) : ( + Please select scope... + )} + + + + } + placeholder="Search projects…" + value={query} + onChange={(e) => setQuery(e.target.value)} + /> + {loading ? ( + + ) : ( +
+ + + + + + + + {filteredItems.map((item) => { + const checked = selectedItems.some((it) => it.id === item.id); + const disabled = disabledIds.has(item.id); + return ( + + + + + ); + })} + +
+ Project
+ { + if (checked) { + onChangeSelectedItems(selectedItems.filter((it) => it.id !== item.id)); + } else { + onChangeSelectedItems([...selectedItems, item]); + } + }} + /> + {item.name}
+
+ )} +
+ + ); +}; diff --git a/config-ui/src/plugins/register/zephyr/index.ts b/config-ui/src/plugins/register/zephyr/index.ts new file mode 100644 index 00000000000..5f16858cbe4 --- /dev/null +++ b/config-ui/src/plugins/register/zephyr/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +export * from './config'; +export * from './transformation'; diff --git a/config-ui/src/plugins/register/zephyr/transformation.tsx b/config-ui/src/plugins/register/zephyr/transformation.tsx new file mode 100644 index 00000000000..1cf01b00c81 --- /dev/null +++ b/config-ui/src/plugins/register/zephyr/transformation.tsx @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { Form, Input } from 'antd'; + +import { Block } from '@/components'; + +interface Props { + entities: string[]; + transformation: any; + setTransformation: React.Dispatch>; +} + +export const ZephyrTransformation = ({ entities, transformation, setTransformation }: Props) => { + if (!entities.includes('CODEQUALITY')) { + return null; + } + + return ( + +
+ + + setTransformation({ + ...transformation, + testTypeCustomField: e.target.value, + }) + } + /> + +
+
+ ); +}; diff --git a/grafana/dashboards/mysql/Zephyr.json b/grafana/dashboards/mysql/Zephyr.json new file mode 100644 index 00000000000..2d7c0eb8493 --- /dev/null +++ b/grafana/dashboards/mysql/Zephyr.json @@ -0,0 +1,1260 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [ + { + "asDropdown": false, + "icon": "bolt", + "includeVars": false, + "keepTime": true, + "tags": [], + "targetBlank": false, + "title": "Homepage", + "tooltip": "", + "type": "link", + "url": "/grafana/d/Lv1XbLHnk/data-specific-dashboards-homepage" + }, + { + "asDropdown": false, + "icon": "external link", + "includeVars": false, + "keepTime": true, + "tags": [ + "Data Source Specific Dashboard" + ], + "targetBlank": false, + "title": "Metric dashboards", + "tooltip": "", + "type": "dashboards", + "url": "" + } + ], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "links": [ + { + "targetBlank": true, + "title": "Zephyr Scale", + "url": "https://smartbear.com/product/zephyr/" + } + ], + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "- Use Cases: This dashboard shows test management metrics from Zephyr Scale (Cloud).\n- Data Source Required: Zephyr\n- Metrics: Test cases, cycles, execution pass rates, and recent execution activity", + "mode": "markdown" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Dashboard Introduction", + "type": "text" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 3 + }, + "id": 2, + "title": "1. Test Execution Overview", + "type": "row" + }, + { + "datasource": "mysql", + "description": "Total number of Zephyr test cases in the selected projects", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 0, + "y": 4 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "select\n count(distinct tc.id) as value\nfrom _tool_zephyr_testcases tc\nwhere\n tc.project_key in (${project_key})", + "refId": "A" + } + ], + "title": "Total Test Cases", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Number of test executions in the selected time range", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "orange", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 4, + "y": 4 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "select\n count(distinct e.id) as value\nfrom _tool_zephyr_testexecutions e\nwhere\n $__timeFilter(e.actual_end_date)\n and e.project_key in (${project_key})", + "refId": "A" + } + ], + "title": "Test Executions", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Percentage of executions with Pass/Passed status", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 1, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 0.7 + }, + { + "color": "green", + "value": 0.9 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 8, + "y": 4 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "select\n count(case when lower(e.test_execution_status_name) in ('pass', 'passed') then 1 else null end) * 1.0\n / nullif(count(*), 0) as value\nfrom _tool_zephyr_testexecutions e\nwhere\n $__timeFilter(e.actual_end_date)\n and e.project_key in (${project_key})", + "refId": "A" + } + ], + "title": "Pass Rate", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Number of test cycles in the selected projects", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "purple", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 12, + "y": 4 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "select\n count(distinct c.id) as value\nfrom _tool_zephyr_testcycles c\nwhere\n c.project_key in (${project_key})", + "refId": "A" + } + ], + "title": "Test Cycles", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Average execution time in seconds", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 1, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 60 + }, + { + "color": "red", + "value": 300 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 16, + "y": 4 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "select\n avg(e.execution_time) / 1000.0 as value\nfrom _tool_zephyr_testexecutions e\nwhere\n $__timeFilter(e.actual_end_date)\n and e.execution_time is not null\n and e.project_key in (${project_key})", + "refId": "A" + } + ], + "title": "Avg Execution Time", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Share of automated vs manual executions", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 1, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 20, + "y": 4 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "select\n count(case when e.automated = 1 then 1 else null end) * 1.0\n / nullif(count(*), 0) as value\nfrom _tool_zephyr_testexecutions e\nwhere\n $__timeFilter(e.actual_end_date)\n and e.project_key in (${project_key})", + "refId": "A" + } + ], + "title": "Automated Share", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Daily pass/fail/other execution counts", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 1, + "drawStyle": "bars", + "fillOpacity": 12, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Passed" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Failed" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Other" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 16, + "x": 0, + "y": 10 + }, + "id": 9, + "options": { + "legend": { + "calcs": [ + "sum" + ], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "format": "time_series", + "rawQuery": true, + "rawSql": "SELECT\n DATE(e.actual_end_date) as time,\n count(case when lower(e.test_execution_status_name) in ('pass', 'passed') then 1 else null end) as \"Passed\",\n count(case when lower(e.test_execution_status_name) in ('fail', 'failed', 'blocked') then 1 else null end) as \"Failed\",\n count(case when lower(coalesce(e.test_execution_status_name, '')) not in ('pass', 'passed', 'fail', 'failed', 'blocked') then 1 else null end) as \"Other\"\nFROM _tool_zephyr_testexecutions e\nWHERE\n $__timeFilter(e.actual_end_date)\n and e.project_key in (${project_key})\nGROUP BY DATE(e.actual_end_date)\nORDER BY time", + "refId": "A" + } + ], + "title": "Execution Trend", + "type": "timeseries" + }, + { + "datasource": "mysql", + "description": "Execution status distribution", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Fail" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Failed" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Pass" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Passed" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 10 + }, + "id": 10, + "options": { + "legend": { + "displayMode": "list", + "placement": "right", + "showLegend": true + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "select\n coalesce(e.test_execution_status_name, 'UNKNOWN') as status,\n count(distinct e.id) as execution_count\nfrom _tool_zephyr_testexecutions e\nwhere\n $__timeFilter(e.actual_end_date)\n and e.project_key in (${project_key})\ngroup by 1\norder by 2 desc", + "refId": "A" + } + ], + "title": "Execution Status Distribution", + "type": "piechart" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 18 + }, + "id": 11, + "title": "2. Test Cases & Cycles", + "type": "row" + }, + { + "datasource": "mysql", + "description": "Test cases by priority", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 19 + }, + "id": 12, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "orientation": "horizontal", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "select\n coalesce(tc.priority_name, 'UNKNOWN') as metric,\n count(distinct tc.id) as \"Test Cases\"\nfrom _tool_zephyr_testcases tc\nwhere\n tc.project_key in (${project_key})\ngroup by 1\norder by 2 desc\nlimit 10", + "refId": "A" + } + ], + "title": "Test Cases by Priority", + "type": "barchart" + }, + { + "datasource": "mysql", + "description": "Test cases by status", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 19 + }, + "id": 13, + "options": { + "legend": { + "displayMode": "list", + "placement": "right", + "showLegend": true + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "select\n coalesce(tc.status_name, 'UNKNOWN') as status,\n count(distinct tc.id) as test_case_count\nfrom _tool_zephyr_testcases tc\nwhere\n tc.project_key in (${project_key})\ngroup by 1\norder by 2 desc", + "refId": "A" + } + ], + "title": "Test Cases by Status", + "type": "piechart" + }, + { + "datasource": "mysql", + "description": "Top folders by test case count", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 19 + }, + "id": 14, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "orientation": "horizontal", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "select\n coalesce(tc.folder_name, 'UNKNOWN') as metric,\n count(distinct tc.id) as \"Test Cases\"\nfrom _tool_zephyr_testcases tc\nwhere\n tc.project_key in (${project_key})\ngroup by 1\norder by 2 desc\nlimit 10", + "refId": "A" + } + ], + "title": "Test Cases by Folder", + "type": "barchart" + }, + { + "datasource": "mysql", + "description": "Executions grouped by test cycle", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "none" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "metric" + }, + "properties": [ + { + "id": "unit", + "value": "none" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 27 + }, + "id": 15, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "orientation": "horizontal", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "select\n CAST(COALESCE(NULLIF(c.name, ''), c.key, 'UNKNOWN') AS CHAR) AS metric,\n count(distinct e.id) as \"Executions\"\nfrom _tool_zephyr_testexecutions e\nleft join _tool_zephyr_testcycles c\n on c.id = e.test_cycle_id and c.connection_id = e.connection_id\nwhere\n $__timeFilter(e.actual_end_date)\n and e.project_key in (${project_key})\ngroup by 1\norder by 2 desc\nlimit 10", + "refId": "A" + } + ], + "title": "Executions by Test Cycle", + "type": "barchart" + }, + { + "datasource": "mysql", + "description": "Test cycles by status", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 27 + }, + "id": 16, + "options": { + "legend": { + "displayMode": "list", + "placement": "right", + "showLegend": true + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "select\n coalesce(c.status_name, 'UNKNOWN') as status,\n count(distinct c.id) as cycle_count\nfrom _tool_zephyr_testcycles c\nwhere\n c.project_key in (${project_key})\ngroup by 1\norder by 2 desc", + "refId": "A" + } + ], + "title": "Test Cycles by Status", + "type": "piechart" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 35 + }, + "id": 17, + "title": "3. Recent Activity", + "type": "row" + }, + { + "datasource": "mysql", + "description": "Most recent test executions", + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 36 + }, + "id": 18, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "select\n e.key as \"Execution Key\",\n tc.key as \"Test Case\",\n coalesce(c.name, c.key) as \"Test Cycle\",\n e.test_execution_status_name as \"Status\",\n e.project_key as \"Project\",\n e.jira_project_version_name as \"Version\",\n case when e.automated = 1 then 'Yes' else 'No' end as \"Automated\",\n e.actual_end_date as \"Executed At\",\n round(e.execution_time / 1000.0, 1) as \"Duration (s)\"\nfrom _tool_zephyr_testexecutions e\nleft join _tool_zephyr_testcases tc\n on tc.id = e.test_case_id and tc.connection_id = e.connection_id\nleft join _tool_zephyr_testcycles c\n on c.id = e.test_cycle_id and c.connection_id = e.connection_id\nwhere\n $__timeFilter(e.actual_end_date)\n and e.project_key in (${project_key})\norder by e.actual_end_date desc\nlimit 50", + "refId": "A" + } + ], + "title": "Recent Test Executions", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 2, + "w": 24, + "x": 0, + "y": 46 + }, + "id": 19, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "
\n\nThis dashboard is created based on this [data schema](https://devlake.apache.org/docs/DataModels/DevLakeDomainLayerSchema). Want to add more metrics? Please follow the [guide](https://devlake.apache.org/docs/Configuration/Dashboards/GrafanaUserGuide).", + "mode": "markdown" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "type": "text" + } + ], + "refresh": "", + "schemaVersion": 38, + "style": "dark", + "tags": [ + "Data Source Dashboard" + ], + "templating": { + "list": [ + { + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "datasource": "mysql", + "definition": "select distinct project_key from _tool_zephyr_projects where project_key is not null", + "hide": 0, + "includeAll": true, + "label": "Project", + "multi": true, + "name": "project_key", + "options": [], + "query": "select distinct project_key from _tool_zephyr_projects where project_key is not null", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "type": "query" + } + ] + }, + "time": { + "from": "now-30d", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "Zephyr", + "uid": "zephyr-dashboard", + "version": 1, + "weekStart": "" +} diff --git a/grafana/dashboards/postgresql/Zephyr.json b/grafana/dashboards/postgresql/Zephyr.json new file mode 100644 index 00000000000..4babacfc760 --- /dev/null +++ b/grafana/dashboards/postgresql/Zephyr.json @@ -0,0 +1,1347 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [ + { + "asDropdown": false, + "icon": "bolt", + "includeVars": false, + "keepTime": true, + "tags": [], + "targetBlank": false, + "title": "Homepage", + "tooltip": "", + "type": "link", + "url": "/grafana/d/Lv1XbLHnk/data-specific-dashboards-homepage" + }, + { + "asDropdown": false, + "icon": "external link", + "includeVars": false, + "keepTime": true, + "tags": [ + "Data Source Specific Dashboard" + ], + "targetBlank": false, + "title": "Metric dashboards", + "tooltip": "", + "type": "dashboards", + "url": "" + } + ], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "links": [ + { + "targetBlank": true, + "title": "Zephyr Scale", + "url": "https://smartbear.com/product/zephyr/" + } + ], + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "- Use Cases: This dashboard shows test management metrics from Zephyr Scale (Cloud).\n- Data Source Required: Zephyr\n- Metrics: Test cases, cycles, execution pass rates, and recent execution activity", + "mode": "markdown" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Dashboard Introduction", + "type": "text" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 3 + }, + "id": 2, + "title": "1. Test Execution Overview", + "type": "row" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "Total number of Zephyr test cases in the selected projects", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 0, + "y": 4 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COUNT(DISTINCT tc.id) AS value FROM _tool_zephyr_testcases AS tc WHERE ('${project_key:csv}' = '' OR tc.project_key::text = ANY(ARRAY[${project_key:singlequote}]::text[]))", + "refId": "A" + } + ], + "title": "Total Test Cases", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "Number of test executions in the selected time range", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "orange", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 4, + "y": 4 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COUNT(DISTINCT e.id) AS value FROM _tool_zephyr_testexecutions AS e WHERE $__timeFilter(e.actual_end_date) AND ('${project_key:csv}' = '' OR e.project_key::text = ANY(ARRAY[${project_key:singlequote}]::text[]))", + "refId": "A" + } + ], + "title": "Test Executions", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "Percentage of executions with Pass/Passed status", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 1, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 0.7 + }, + { + "color": "green", + "value": 0.9 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 8, + "y": 4 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT CAST(COUNT(CASE WHEN LOWER(e.test_execution_status_name) IN ('pass', 'passed') THEN 1 ELSE NULL END) * 1.0 AS NUMERIC) / NULLIF(NULLIF(COUNT(*), 0), 0) AS value FROM _tool_zephyr_testexecutions AS e WHERE $__timeFilter(e.actual_end_date) AND ('${project_key:csv}' = '' OR e.project_key::text = ANY(ARRAY[${project_key:singlequote}]::text[]))", + "refId": "A" + } + ], + "title": "Pass Rate", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "Number of test cycles in the selected projects", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "purple", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 12, + "y": 4 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COUNT(DISTINCT c.id) AS value FROM _tool_zephyr_testcycles AS c WHERE ('${project_key:csv}' = '' OR c.project_key::text = ANY(ARRAY[${project_key:singlequote}]::text[]))", + "refId": "A" + } + ], + "title": "Test Cycles", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "Average execution time in seconds", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 1, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 60 + }, + { + "color": "red", + "value": 300 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 16, + "y": 4 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT CAST(AVG(e.execution_time) AS NUMERIC) / NULLIF(1000.0, 0) AS value FROM _tool_zephyr_testexecutions AS e WHERE $__timeFilter(e.actual_end_date) AND NOT e.execution_time IS NULL AND ('${project_key:csv}' = '' OR e.project_key::text = ANY(ARRAY[${project_key:singlequote}]::text[]))", + "refId": "A" + } + ], + "title": "Avg Execution Time", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "Share of automated vs manual executions", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 1, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 20, + "y": 4 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT CAST(COUNT(CASE WHEN e.automated = TRUE THEN 1 ELSE NULL END) * 1.0 AS NUMERIC) / NULLIF(NULLIF(COUNT(*), 0), 0) AS value FROM _tool_zephyr_testexecutions AS e WHERE $__timeFilter(e.actual_end_date) AND ('${project_key:csv}' = '' OR e.project_key::text = ANY(ARRAY[${project_key:singlequote}]::text[]))", + "refId": "A" + } + ], + "title": "Automated Share", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "Daily pass/fail/other execution counts", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 1, + "drawStyle": "bars", + "fillOpacity": 12, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Passed" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Failed" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Other" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 16, + "x": 0, + "y": 10 + }, + "id": 9, + "options": { + "legend": { + "calcs": [ + "sum" + ], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "format": "time_series", + "rawQuery": true, + "rawSql": "SELECT CAST(e.actual_end_date AS DATE) AS time, COUNT(CASE WHEN LOWER(e.test_execution_status_name) IN ('pass', 'passed') THEN 1 ELSE NULL END) AS \"Passed\", COUNT(CASE WHEN LOWER(e.test_execution_status_name) IN ('fail', 'failed', 'blocked') THEN 1 ELSE NULL END) AS \"Failed\", COUNT(CASE WHEN NOT LOWER(COALESCE(e.test_execution_status_name, '')) IN ('pass', 'passed', 'fail', 'failed', 'blocked') THEN 1 ELSE NULL END) AS \"Other\" FROM _tool_zephyr_testexecutions AS e WHERE $__timeFilter(e.actual_end_date) AND ('${project_key:csv}' = '' OR e.project_key::text = ANY(ARRAY[${project_key:singlequote}]::text[])) GROUP BY CAST(e.actual_end_date AS DATE) ORDER BY time NULLS FIRST", + "refId": "A" + } + ], + "title": "Execution Trend", + "type": "timeseries" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "Execution status distribution", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Fail" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Failed" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Pass" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Passed" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 10 + }, + "id": 10, + "options": { + "legend": { + "displayMode": "list", + "placement": "right", + "showLegend": true + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COALESCE(e.test_execution_status_name, 'UNKNOWN') AS status, COUNT(DISTINCT e.id) AS execution_count FROM _tool_zephyr_testexecutions AS e WHERE $__timeFilter(e.actual_end_date) AND ('${project_key:csv}' = '' OR e.project_key::text = ANY(ARRAY[${project_key:singlequote}]::text[])) GROUP BY 1 ORDER BY 2 DESC NULLS LAST", + "refId": "A" + } + ], + "title": "Execution Status Distribution", + "type": "piechart" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 18 + }, + "id": 11, + "title": "2. Test Cases & Cycles", + "type": "row" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "Test cases by priority", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 19 + }, + "id": 12, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "orientation": "horizontal", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COALESCE(tc.priority_name, 'UNKNOWN') AS metric, COUNT(DISTINCT tc.id) AS \"Test Cases\" FROM _tool_zephyr_testcases AS tc WHERE ('${project_key:csv}' = '' OR tc.project_key::text = ANY(ARRAY[${project_key:singlequote}]::text[])) GROUP BY 1 ORDER BY 2 DESC NULLS LAST LIMIT 10", + "refId": "A" + } + ], + "title": "Test Cases by Priority", + "type": "barchart" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "Test cases by status", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 19 + }, + "id": 13, + "options": { + "legend": { + "displayMode": "list", + "placement": "right", + "showLegend": true + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COALESCE(tc.status_name, 'UNKNOWN') AS status, COUNT(DISTINCT tc.id) AS test_case_count FROM _tool_zephyr_testcases AS tc WHERE ('${project_key:csv}' = '' OR tc.project_key::text = ANY(ARRAY[${project_key:singlequote}]::text[])) GROUP BY 1 ORDER BY 2 DESC NULLS LAST", + "refId": "A" + } + ], + "title": "Test Cases by Status", + "type": "piechart" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "Top folders by test case count", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 19 + }, + "id": 14, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "orientation": "horizontal", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COALESCE(tc.folder_name, 'UNKNOWN') AS metric, COUNT(DISTINCT tc.id) AS \"Test Cases\" FROM _tool_zephyr_testcases AS tc WHERE ('${project_key:csv}' = '' OR tc.project_key::text = ANY(ARRAY[${project_key:singlequote}]::text[])) GROUP BY 1 ORDER BY 2 DESC NULLS LAST LIMIT 10", + "refId": "A" + } + ], + "title": "Test Cases by Folder", + "type": "barchart" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "Executions grouped by test cycle", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "none" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "metric" + }, + "properties": [ + { + "id": "unit", + "value": "none" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 27 + }, + "id": 15, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "orientation": "horizontal", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT CAST(COALESCE(NULLIF(c.name, ''), c.key, 'UNKNOWN') AS TEXT) AS metric, COUNT(DISTINCT e.id) AS \"Executions\" FROM _tool_zephyr_testexecutions AS e LEFT JOIN _tool_zephyr_testcycles AS c ON c.id = e.test_cycle_id AND c.connection_id = e.connection_id WHERE $__timeFilter(e.actual_end_date) AND ('${project_key:csv}' = '' OR e.project_key::text = ANY(ARRAY[${project_key:singlequote}]::text[])) GROUP BY 1 ORDER BY 2 DESC NULLS LAST LIMIT 10", + "refId": "A" + } + ], + "title": "Executions by Test Cycle", + "type": "barchart" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "Test cycles by status", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 27 + }, + "id": 16, + "options": { + "legend": { + "displayMode": "list", + "placement": "right", + "showLegend": true + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COALESCE(c.status_name, 'UNKNOWN') AS status, COUNT(DISTINCT c.id) AS cycle_count FROM _tool_zephyr_testcycles AS c WHERE ('${project_key:csv}' = '' OR c.project_key::text = ANY(ARRAY[${project_key:singlequote}]::text[])) GROUP BY 1 ORDER BY 2 DESC NULLS LAST", + "refId": "A" + } + ], + "title": "Test Cycles by Status", + "type": "piechart" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 35 + }, + "id": 17, + "title": "3. Recent Activity", + "type": "row" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "Most recent test executions", + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 36 + }, + "id": 18, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT e.key AS \"Execution Key\", tc.key AS \"Test Case\", COALESCE(c.name, c.key) AS \"Test Cycle\", e.test_execution_status_name AS \"Status\", e.project_key AS \"Project\", e.jira_project_version_name AS \"Version\", CASE WHEN e.automated = TRUE THEN 'Yes' ELSE 'No' END AS \"Automated\", e.actual_end_date AS \"Executed At\", ROUND(CAST(CAST(e.execution_time AS NUMERIC) / NULLIF(1000.0, 0) AS DECIMAL), 1) AS \"Duration (s)\" FROM _tool_zephyr_testexecutions AS e LEFT JOIN _tool_zephyr_testcases AS tc ON tc.id = e.test_case_id AND tc.connection_id = e.connection_id LEFT JOIN _tool_zephyr_testcycles AS c ON c.id = e.test_cycle_id AND c.connection_id = e.connection_id WHERE $__timeFilter(e.actual_end_date) AND ('${project_key:csv}' = '' OR e.project_key::text = ANY(ARRAY[${project_key:singlequote}]::text[])) ORDER BY e.actual_end_date DESC NULLS LAST LIMIT 50", + "refId": "A" + } + ], + "title": "Recent Test Executions", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 2, + "w": 24, + "x": 0, + "y": 46 + }, + "id": 19, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "
\n\nThis dashboard is created based on this [data schema](https://devlake.apache.org/docs/DataModels/DevLakeDomainLayerSchema). Want to add more metrics? Please follow the [guide](https://devlake.apache.org/docs/Configuration/Dashboards/GrafanaUserGuide).", + "mode": "markdown" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "type": "text" + } + ], + "refresh": "", + "schemaVersion": 38, + "style": "dark", + "tags": [ + "Data Source Dashboard" + ], + "templating": { + "list": [ + { + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "definition": "SELECT DISTINCT project_key FROM _tool_zephyr_projects WHERE NOT project_key IS NULL", + "hide": 0, + "includeAll": true, + "label": "Project", + "multi": true, + "name": "project_key", + "options": [], + "query": "SELECT DISTINCT project_key FROM _tool_zephyr_projects WHERE NOT project_key IS NULL", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "type": "query" + } + ] + }, + "time": { + "from": "now-30d", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "Zephyr", + "uid": "zephyr-dashboard-pg", + "version": 1, + "weekStart": "" +}