diff --git a/CHANGELOG.md b/CHANGELOG.md index c977606f..4f59d647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ ### 1.0.4-dev +* `lobster-codebeamer`: + - Added `baseline_id` support for cbQL queries. When set, the tool queries + items at the specified Codebeamer baseline revision. + - `baseline_id` combined with `import_tagged` or a numeric `import_query` + now raises an error instead of being silently ignored (ISO 26262 TCL3 + qualification support). + * `trlc bazel dep`: update to trlc==2.0.5 * Fixed wrong links in [README](README.md). diff --git a/documentation/manual-lobster_codebeamer.md b/documentation/manual-lobster_codebeamer.md index 6bc1fedc..8d09c4e6 100644 --- a/documentation/manual-lobster_codebeamer.md +++ b/documentation/manual-lobster_codebeamer.md @@ -122,6 +122,23 @@ Then invoke the `lobster-codebeamer` tool like so: ```bash $ lobster-codebeamer --import-query 4776335 --out system-requirements.lobster ``` + +### Querying at a Baseline + +To restrict results to the state of a specific Codebeamer baseline, add `baseline_id` +to the YAML config file: + +```yaml +import_query: "tracker.id IN (29782591) AND status IN ('Approved')" +baseline_id: 407126303 +``` + +> **Important:** `baseline_id` is only applied when `import_query` is a **cbQL query +> string**. When `import_query` is a numeric report ID or `import_tagged` (a path to +> an existing LOBSTER artifact for tag-based import, see +> [Importing only tagged requirements](#importing-only-tagged-requirements)) is set, +> `lobster-codebeamer` exits with an error (exit code 1). + ### Importing only tagged requirements If you are not interested in a completeness check, or your diff --git a/lobster/tools/codebeamer/codebeamer.py b/lobster/tools/codebeamer/codebeamer.py index 85dce376..171fea44 100755 --- a/lobster/tools/codebeamer/codebeamer.py +++ b/lobster/tools/codebeamer/codebeamer.py @@ -72,6 +72,7 @@ class SupportedConfigKeys(Enum): RETRY_ERROR_CODES = "retry_error_codes" IMPORT_TAGGED = "import_tagged" IMPORT_QUERY = "import_query" + BASELINE_ID = "baseline_id" VERIFY_SSL = "verify_ssl" PAGE_SIZE = "page_size" REFS = "refs" @@ -231,6 +232,8 @@ def get_query(cb_config: Config, query: Union[int, str]): elif isinstance(query, str): url = (f"{cb_config.base}/items/query?page={page_id}" f"&pageSize={cb_config.page_size}&queryString={query}") + if cb_config.baseline_id is not None: + url += f"&baselineId={cb_config.baseline_id}" data = query_cb_single(cb_config, url) if len(data) != 4: raise MismatchException( @@ -505,6 +508,7 @@ def parse_config_data(data: dict) -> Config: references=ensure_list(data.get(SupportedConfigKeys.REFS.value, [])), import_tagged=data.get(SupportedConfigKeys.IMPORT_TAGGED.value), import_query=data.get(SupportedConfigKeys.IMPORT_QUERY.value), + baseline_id=data.get(SupportedConfigKeys.BASELINE_ID.value), verify_ssl=data.get(SupportedConfigKeys.VERIFY_SSL.value, True), page_size=data.get(SupportedConfigKeys.PAGE_SIZE.value, 100), schema=data.get(SupportedConfigKeys.SCHEMA.value, "Requirement"), @@ -532,6 +536,30 @@ def parse_config_data(data: dict) -> Config: raise KeyError(f"{SupportedConfigKeys.CB_ROOT.value} must start with https://, " f"but value is {config.cb_auth_conf.root}.") + if config.baseline_id is not None: + if config.import_tagged: + raise KeyError( + f"The keys {SupportedConfigKeys.BASELINE_ID.value} and " + f"{SupportedConfigKeys.IMPORT_TAGGED.value} are both present " + f"in the configuration, but they are mutually exclusive!" + ) + if config.import_query and not isinstance(config.import_query, str): + raise KeyError( + f"The key {SupportedConfigKeys.BASELINE_ID.value} is only " + f"allowed if {SupportedConfigKeys.IMPORT_QUERY.value} is a " + f"cbQL query string, not a numeric report ID!" + ) + try: + config.baseline_id = int(config.baseline_id) + except (TypeError, ValueError) as exc: + raise ValueError( + f"{SupportedConfigKeys.BASELINE_ID.value} must be a positive integer." + ) from exc + if config.baseline_id <= 0: + raise ValueError( + f"{SupportedConfigKeys.BASELINE_ID.value} must be a positive integer." + ) + return config @@ -571,6 +599,8 @@ def _run_impl(self, options: argparse.Namespace) -> int: ) except ValueError as value_error: self._print_error(value_error) + except KeyError as key_error: + self._print_error(key_error) except LOBSTER_Error as lobster_error: self._print_error(lobster_error) diff --git a/lobster/tools/codebeamer/config.py b/lobster/tools/codebeamer/config.py index be582e6b..2a30097a 100644 --- a/lobster/tools/codebeamer/config.py +++ b/lobster/tools/codebeamer/config.py @@ -17,6 +17,7 @@ class Config: references: dict import_tagged: str import_query: Union[str, int] + baseline_id: Optional[int] verify_ssl: bool page_size: int schema: str diff --git a/manual/tools/codebeamer.rst b/manual/tools/codebeamer.rst index 457df03d..fde29a20 100644 --- a/manual/tools/codebeamer.rst +++ b/manual/tools/codebeamer.rst @@ -44,6 +44,7 @@ Config references=[], import_tagged=None, import_query=1234, # report ID or cbQL query string + baseline_id=None, verify_ssl=True, page_size=100, schema="requirement", # or "implementation" / "activity" @@ -57,6 +58,7 @@ Config - ``references`` (List[str]): Names of Codebeamer fields whose referenced items should be traced (converted to ``req`` tags). - ``import_tagged`` (str | None): Path to an existing LOBSTER artifact whose unresolved ``req`` references define item IDs to import. - ``import_query`` (int | str | None): Report ID (int) or cbQL query string used to fetch items directly. +- ``baseline_id`` (int | None): Codebeamer baseline ID to query against. **Only allowed when** ``import_query`` **is a cbQL query string**. Raises an error if combined with ``import_tagged`` or a numeric ``import_query``. - ``verify_ssl`` (bool): Whether to verify TLS certificates; set ``True`` in production for security. - ``page_size`` (int): Pagination size for REST queries; a trade-off between round trips and response size (default typically 100). - ``schema`` (str): Target schema type (``requirement``, ``implementation``, ``activity``) controlling class/namespace mapping. @@ -122,3 +124,6 @@ Error Conditions - Absent both ``import_query`` & ``import_tagged`` → ``KeyError``. - ``num_request_retry <= 0`` → ``ValueError``. - Unrecognised ``schema`` → ``KeyError``. +- ``baseline_id`` is not a positive integer → ``ValueError``. +- ``baseline_id`` combined with ``import_tagged`` → ``KeyError``. +- ``baseline_id`` combined with numeric ``import_query`` → ``KeyError``. diff --git a/tests_system/lobster_codebeamer/lobster_codebeamer_test_runner.py b/tests_system/lobster_codebeamer/lobster_codebeamer_test_runner.py index 229d469a..13f24a03 100644 --- a/tests_system/lobster_codebeamer/lobster_codebeamer_test_runner.py +++ b/tests_system/lobster_codebeamer/lobster_codebeamer_test_runner.py @@ -9,6 +9,8 @@ @dataclass class ConfigFileData: import_query: Optional[Union[int, str]] = None + import_tagged: Optional[str] = None + baseline_id: Optional[int] = None root: Optional[str] = None token: Optional[str] = None out: Optional[str] = None @@ -31,6 +33,8 @@ def append_if_not_none(key, value): data[key] = value append_if_not_none("import_query", self.import_query) + append_if_not_none("import_tagged", self.import_tagged) + append_if_not_none("baseline_id", self.baseline_id) append_if_not_none("root", self.root) append_if_not_none("token", self.token) append_if_not_none("out", self.out) diff --git a/tests_system/lobster_codebeamer/test_baseline_id.py b/tests_system/lobster_codebeamer/test_baseline_id.py new file mode 100644 index 00000000..f4a27d51 --- /dev/null +++ b/tests_system/lobster_codebeamer/test_baseline_id.py @@ -0,0 +1,133 @@ +import json +import unittest +from flask import Response +from tests_system.lobster_codebeamer.lobster_codebeamer_system_test_case_base import ( + LobsterCodebeamerSystemTestCaseBase) +from tests_system.asserter import Asserter +from tests_system.lobster_codebeamer.lobster_codebeamer_asserter import ( + LobsterCodebeamerAsserter) +from tests_system.lobster_codebeamer.mock_server_setup import get_mock_app + + +class LobsterCodebeamerBaselineIdTest(LobsterCodebeamerSystemTestCaseBase): + """System tests for baseline_id validation and usage.""" + + @classmethod + def setUpClass(cls): + cls.codebeamer_flask = get_mock_app() + + def setUp(self): + super().setUp() + self.codebeamer_flask.reset() + self._test_runner = self.create_test_runner() + self._test_runner.config_file_data.verify_ssl = False + + def test_baseline_id_with_import_tagged_raises_error(self): + """Ensure baseline_id combined with import_tagged exits with error.""" + cfg = self._test_runner.config_file_data + cfg.set_default_root_token_out(self.codebeamer_flask.port) + cfg.import_tagged = "some_file.lobster" + cfg.baseline_id = 12345 + + completed_process = self._test_runner.run_tool_test() + asserter = Asserter(self, completed_process, self._test_runner) + asserter.assertExitCode(1) + asserter.assertStdErrText( + "lobster-codebeamer: 'The keys baseline_id and import_tagged " + "are both present in the configuration, but they are mutually " + "exclusive!'\n" + ) + + def test_baseline_id_with_numeric_import_query_raises_error(self): + """Ensure baseline_id combined with a numeric import_query exits + with error.""" + cfg = self._test_runner.config_file_data + cfg.set_default_root_token_out(self.codebeamer_flask.port) + cfg.import_query = 9999 + cfg.baseline_id = 12345 + + completed_process = self._test_runner.run_tool_test() + asserter = Asserter(self, completed_process, self._test_runner) + asserter.assertExitCode(1) + asserter.assertStdErrText( + "lobster-codebeamer: 'The key baseline_id is only allowed if " + "import_query is a cbQL query string, not a numeric report ID!'\n" + ) + + def test_baseline_id_negative_raises_error(self): + """Ensure a negative baseline_id exits with error.""" + cfg = self._test_runner.config_file_data + cfg.set_default_root_token_out(self.codebeamer_flask.port) + cfg.import_query = "tracker.id IN (123)" + cfg.baseline_id = -1 + + completed_process = self._test_runner.run_tool_test() + asserter = Asserter(self, completed_process, self._test_runner) + asserter.assertExitCode(1) + asserter.assertStdErrText( + "lobster-codebeamer: baseline_id must be a positive integer.\n" + ) + + def test_baseline_id_zero_raises_error(self): + """Ensure baseline_id of 0 exits with error.""" + cfg = self._test_runner.config_file_data + cfg.set_default_root_token_out(self.codebeamer_flask.port) + cfg.import_query = "tracker.id IN (123)" + cfg.baseline_id = 0 + + completed_process = self._test_runner.run_tool_test() + asserter = Asserter(self, completed_process, self._test_runner) + asserter.assertExitCode(1) + asserter.assertStdErrText( + "lobster-codebeamer: baseline_id must be a positive integer.\n" + ) + + def test_baseline_id_with_cbql_query_succeeds(self): + """Ensure baseline_id with a cbQL string query works and appends + baselineId to the URL.""" + cfg = self._test_runner.config_file_data + cfg.set_default_root_token_out(self.codebeamer_flask.port) + cfg.import_query = "tracker.id IN (123)" + cfg.baseline_id = 407126303 + + response_data = { + 'page': 1, + 'pageSize': 1, + 'total': 1, + 'items': [ + { + 'id': 5, + 'name': 'Requirement 5: Dynamic name', + 'description': 'Dynamic description', + 'status': { + 'id': 5, + 'name': 'Status 5', + 'type': 'ChoiceOptionReference' + }, + 'tracker': { + 'id': 5, + 'name': 'Tracker_Name_5', + 'type': 'TrackerReference', + }, + 'version': 1, + } + ] + } + self.codebeamer_flask.responses = [ + Response(json.dumps(response_data), status=200), + ] + self._test_runner.declare_output_file( + self._data_directory / cfg.out) + + completed_process = self._test_runner.run_tool_test() + asserter = Asserter(self, completed_process, self._test_runner) + asserter.assertExitCode(0) + + # Verify the baselineId parameter was included in the request URL + self.assertEqual(len(self.codebeamer_flask.received_requests), 1) + request_url = self.codebeamer_flask.received_requests[0]["url"] + self.assertIn("baselineId=407126303", request_url) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests_unit/lobster_codebeamer/test_codebeamer.py b/tests_unit/lobster_codebeamer/test_codebeamer.py index a242bb9e..f4f59427 100644 --- a/tests_unit/lobster_codebeamer/test_codebeamer.py +++ b/tests_unit/lobster_codebeamer/test_codebeamer.py @@ -28,6 +28,7 @@ def setUp(self): references=None, import_tagged=None, import_query=None, + baseline_id=None, verify_ssl=None, page_size=10, schema="Requirement", @@ -118,6 +119,36 @@ def test_get_query_with_query(self, mock_query_cb_single): self.assertEqual(result[0].location.tracker, item_data[0]["tracker"]["id"]) self.assertEqual(result[0].location.version, item_data[0]["version"]) + @patch('lobster.tools.codebeamer.codebeamer.query_cb_single') + def test_get_query_with_query_and_baseline_id(self, mock_query_cb_single): + """baseline_id is appended to the URL when import_query is a cbQL string.""" + self._mock_cb_config.baseline_id = 407126303 + mock_query = "tracker.id IN (29782591)" + mock_query_cb_single.return_value = { + "page": 1, "pageSize": 10, "total": 0, "items": [] + } + get_query(self._mock_cb_config, mock_query) + called_url = mock_query_cb_single.call_args[0][1] + self.assertIn("baselineId=407126303", called_url) + + @patch('lobster.tools.codebeamer.codebeamer.query_cb_single') + def test_get_query_with_ID_and_baseline_id(self, mock_query_cb_single): + """baseline_id is NOT appended to the URL when import_query is a report ID.""" + self._mock_cb_config.baseline_id = 407126303 + mock_query = 171619121 + item_data = [{"item": { + "id": 1, "name": "x", "version": 1, + "tracker": {"id": 1}, + "categories": [{"name": "Requirement"}], + "status": {"name": "Draft"}, + }}] + mock_query_cb_single.return_value = { + "page": 1, "pageSize": 10, "total": 1, "items": item_data + } + get_query(self._mock_cb_config, mock_query) + called_url = mock_query_cb_single.call_args[0][1] + self.assertNotIn("baselineId", called_url) + @patch('lobster.tools.codebeamer.codebeamer.query_cb_single') def test_get_query_with_invalid_data(self, mock_query_cb_single): query_id = 789