From e90753f7bdbe6760ad0799218b4292512f5de3cb Mon Sep 17 00:00:00 2001
From: Roger Abelenda <3179183+rabelenda@users.noreply.github.com>
Date: Wed, 28 Oct 2026 11:22:33 -0300
Subject: [PATCH] feat(zephyr): add Zephyr plugin and dashboard
Collect Zephyr Scale Cloud projects, test cases, cycles, and executions
into the QA domain, with MySQL and PostgreSQL Grafana dashboards.
PyDevLake needs QA domain models so the plugin can map test cases and
executions. QaProject implements the scope interface, and the remote
runner recognizes that scope type. Collection uses the project timeAfter
already forwarded by the remote runner.
Refs #9270
Signed-off-by: Roger Abelenda <3179183+rabelenda@users.noreply.github.com>
---
.github/dependabot.yml | 1 +
.../core/models/domainlayer/qa/qa_project.go | 11 +
backend/python/plugins/zephyr/README.md | 50 +
backend/python/plugins/zephyr/build.sh | 20 +
backend/python/plugins/zephyr/conftest.py | 23 +
backend/python/plugins/zephyr/pyproject.toml | 38 +
backend/python/plugins/zephyr/run.sh | 20 +
.../python/plugins/zephyr/tests/api_test.py | 58 +
.../plugins/zephyr/tests/collection_test.py | 175 +++
.../plugins/zephyr/tests/plugin_test.py | 22 +
.../plugins/zephyr/tests/streams_test.py | 239 +++
.../python/plugins/zephyr/tests/utils_test.py | 39 +
.../python/plugins/zephyr/zephyr/__init__.py | 15 +
backend/python/plugins/zephyr/zephyr/api.py | 146 ++
backend/python/plugins/zephyr/zephyr/main.py | 106 ++
.../plugins/zephyr/zephyr/migrations.py | 138 ++
.../python/plugins/zephyr/zephyr/models.py | 135 ++
.../plugins/zephyr/zephyr/streams/__init__.py | 15 +
.../plugins/zephyr/zephyr/streams/folders.py | 49 +
.../zephyr/streams/test_case_issue_links.py | 53 +
.../zephyr/zephyr/streams/test_case_labels.py | 48 +
.../zephyr/zephyr/streams/test_cases.py | 139 ++
.../zephyr/zephyr/streams/test_cycles.py | 90 ++
.../streams/test_execution_issue_links.py | 53 +
.../zephyr/zephyr/streams/test_executions.py | 161 ++
backend/python/plugins/zephyr/zephyr/utils.py | 121 ++
.../pydevlake/pydevlake/domain_layer/qa.py | 47 +
.../remote/models/dynamic_domain_scopes.go | 3 +
.../components/scope-config-form/index.tsx | 9 +
config-ui/src/plugins/register/index.ts | 2 +
.../plugins/register/zephyr/assets/icon.svg | 22 +
.../src/plugins/register/zephyr/config.tsx | 69 +
.../plugins/register/zephyr/data-scope.tsx | 175 +++
.../src/plugins/register/zephyr/index.ts | 20 +
.../register/zephyr/transformation.tsx | 55 +
grafana/dashboards/mysql/Zephyr.json | 1260 +++++++++++++++
grafana/dashboards/postgresql/Zephyr.json | 1347 +++++++++++++++++
37 files changed, 4974 insertions(+)
create mode 100644 backend/python/plugins/zephyr/README.md
create mode 100755 backend/python/plugins/zephyr/build.sh
create mode 100644 backend/python/plugins/zephyr/conftest.py
create mode 100644 backend/python/plugins/zephyr/pyproject.toml
create mode 100755 backend/python/plugins/zephyr/run.sh
create mode 100644 backend/python/plugins/zephyr/tests/api_test.py
create mode 100644 backend/python/plugins/zephyr/tests/collection_test.py
create mode 100644 backend/python/plugins/zephyr/tests/plugin_test.py
create mode 100644 backend/python/plugins/zephyr/tests/streams_test.py
create mode 100644 backend/python/plugins/zephyr/tests/utils_test.py
create mode 100644 backend/python/plugins/zephyr/zephyr/__init__.py
create mode 100644 backend/python/plugins/zephyr/zephyr/api.py
create mode 100644 backend/python/plugins/zephyr/zephyr/main.py
create mode 100644 backend/python/plugins/zephyr/zephyr/migrations.py
create mode 100644 backend/python/plugins/zephyr/zephyr/models.py
create mode 100644 backend/python/plugins/zephyr/zephyr/streams/__init__.py
create mode 100644 backend/python/plugins/zephyr/zephyr/streams/folders.py
create mode 100644 backend/python/plugins/zephyr/zephyr/streams/test_case_issue_links.py
create mode 100644 backend/python/plugins/zephyr/zephyr/streams/test_case_labels.py
create mode 100644 backend/python/plugins/zephyr/zephyr/streams/test_cases.py
create mode 100644 backend/python/plugins/zephyr/zephyr/streams/test_cycles.py
create mode 100644 backend/python/plugins/zephyr/zephyr/streams/test_execution_issue_links.py
create mode 100644 backend/python/plugins/zephyr/zephyr/streams/test_executions.py
create mode 100644 backend/python/plugins/zephyr/zephyr/utils.py
create mode 100644 backend/python/pydevlake/pydevlake/domain_layer/qa.py
create mode 100644 config-ui/src/plugins/register/zephyr/assets/icon.svg
create mode 100644 config-ui/src/plugins/register/zephyr/config.tsx
create mode 100644 config-ui/src/plugins/register/zephyr/data-scope.tsx
create mode 100644 config-ui/src/plugins/register/zephyr/index.ts
create mode 100644 config-ui/src/plugins/register/zephyr/transformation.tsx
create mode 100644 grafana/dashboards/mysql/Zephyr.json
create mode 100644 grafana/dashboards/postgresql/Zephyr.json
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 ? (
+
+ ) : (
+