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 ? (
+
+ ) : (
+