Closes #1026 - Add OrgApp and OrgAppAudience new item types support (#1155)

This pull request adds support for publishing and managing "Org App" and
"Org App Audience" item types in the deployment pipeline. It introduces
new publisher classes, updates constants and mappings, enhances
documentation, and adds comprehensive tests to ensure correct handling
of these new item types, including their relationships and deployment
order.

**Support for new item types: Org App and Org App Audience**

- Added `OrgAppPublisher` and `OrgAppAudiencePublisher` classes to
handle publishing logic for the new item types
(`src/fabric_cicd/_items/_orgapp.py`,
`src/fabric_cicd/_items/_orgappaudience.py`)
[[1]](diffhunk://#diff-f530c5a6a9918ed3f60e147c9dc1622bbee4054c217183e3cd58bdbfb0cb2401R1-R20)
[[2]](diffhunk://#diff-a637318ff073320392d7046a9539f7b33dbee4955718b94bd0081e6888794e0dR1-R13).
- Updated the publisher factory and item type mappings to recognize and
instantiate these publishers
(`src/fabric_cicd/_items/_base_publisher.py`,
`src/fabric_cicd/constants.py`)
[[1]](diffhunk://#diff-a077ffe516cef014bef47cce16065137725effecb517d141167e72d082592c3fR165-R166)
[[2]](diffhunk://#diff-a077ffe516cef014bef47cce16065137725effecb517d141167e72d082592c3fR208-R209)
[[3]](diffhunk://#diff-91c2d32be351f155f090c4bd57ebe3c10e646edbc1b663e2a72b697f956a949cR71-R72)
[[4]](diffhunk://#diff-91c2d32be351f155f090c4bd57ebe3c10e646edbc1b663e2a72b697f956a949cR118-R119).
- Added exclusion rules for Org App children to ensure correct payload
structure (`src/fabric_cicd/constants.py`).

---------

Co-authored-by: Alon Yeshurun <alonyeshurun+microsoft@microsoft.com>
Co-authored-by: shirasassoon <66449905+shirasassoon@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-19 09:44:23 +03:00
1 parent f20861af06
commit e0d20cc167
14 files changed
+296 -6

No files matched your search

@@ -0,0 +1,8 @@
kind: new-items
body: Add support for Org App and Org App Audience items
time: 2026-10-08T12:07:25.20395+03:00
custom:
Author: ayeshurun
AuthorLink: https://github.com/ayeshurun
Issue: "1026"
IssueLink: https://github.com/microsoft/fabric-cicd/issues/1026
+6 -2
View File
@@ -6,7 +6,11 @@
import sys
from pathlib import Path
from azure.identity import AzureCliCredential, AzurePowerShellCredential, ClientSecretCredential
from azure.identity import (
AzureCliCredential,
AzurePowerShellCredential,
ClientSecretCredential,
)
root_directory = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(root_directory / "src"))
@@ -14,7 +18,7 @@ sys.path.insert(0, str(root_directory / "src"))
from fabric_cicd import append_feature_flag, change_log_level, deploy_with_config
# Uncomment to enable debug
# change_log_level()
change_log_level()
# In this example, the config file sits within the root/sample/workspace directory
config_file = str(root_directory / "sample" / "workspace" / "config.yml")
+11
View File
@@ -161,6 +161,17 @@
- Referenced items that exist in a different workspace will always point to the original item unless parameterized in the `find_replace` section of the `parameter.yml` file.
- Referenced items within the same workspace are automatically re-pointed to the new item in the target workspace.
## Org App
- **Parameterization:**
- Embedded items within the same workspace are automatically re-pointed to the new item in the target workspace.
## Org App Audience
- **Parameterization:**
- The parent Org App reference (`parentAppId`) and referenced content items are automatically re-pointed to the deployed items in the target workspace.
- In Fabric, an Org App Audience is not a standalone item. However, during deployment, it is treated as such. Its source control files are located within a `.children` folder under the directory of the parent Org App.
## Paginated Report
- **Parameterization:**
@@ -0,0 +1,11 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/fabric/gitIntegration/platformProperties/2.0.0/schema.json",
"metadata": {
"type": "OrgAppAudience",
"displayName": "OrgApp_ABC"
},
"config": {
"version": "2.0",
"logicalId": "84da0817-74f5-b51a-48aa-402f418745cd"
}
}
@@ -0,0 +1,15 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/fabric/item/orgappaudience/definition/orgAppAudienceDefinition/1.0.0/schema.json",
"settings": {
"hasAccessToHiddenContent": false,
"tabOrder": 0
},
"parentAppId": "13889f2d-6b42-8aca-4ff0-5528b5e9c284",
"elementReferences": [
{
"elementId": "d41e08fa-7283-4cb2-82ce-b9d3f7f62044",
"itemType": "Notebook",
"itemLogicalId": "99b570c5-0c79-9dc4-4c9b-fa16c621384c"
}
]
}
@@ -0,0 +1,11 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/fabric/gitIntegration/platformProperties/2.0.0/schema.json",
"metadata": {
"type": "OrgApp",
"displayName": "OrgApp_ABC"
},
"config": {
"version": "2.0",
"logicalId": "13889f2d-6b42-8aca-4ff0-5528b5e9c284"
}
}
@@ -0,0 +1,13 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/fabric/item/orgapp/definition/orgAppDefinition/2.0.0/schema.json",
"settings": {},
"elements": [
{
"elementType": "item",
"elementId": "d41e08fa-7283-4cb2-82ce-b9d3f7f62044",
"itemType": "Notebook",
"itemLogicalId": "99b570c5-0c79-9dc4-4c9b-fa16c621384c",
"displayName": "Hello World"
}
]
}
+2
View File
@@ -55,6 +55,8 @@ unpublish: # Unpublish configuration (optional)
features: # Feature flags to enable (optional)
- enable_shortcut_publish
- enable_experimental_features
- enable_bulk_publish
constants: # Global constants to override (optional)
DEFAULT_API_ROOT_URL: "https://msitapi.fabric.microsoft.com"
@@ -162,6 +162,8 @@ class ItemPublisher(Publisher):
from fabric_cicd._items._mounteddatafactory import MountedDataFactoryPublisher
from fabric_cicd._items._notebook import NotebookPublisher
from fabric_cicd._items._ontology import OntologyPublisher
from fabric_cicd._items._orgapp import OrgAppPublisher
from fabric_cicd._items._orgappaudience import OrgAppAudiencePublisher
from fabric_cicd._items._paginatedreport import PaginatedReportPublisher
from fabric_cicd._items._report import ReportPublisher
from fabric_cicd._items._semanticmodel import SemanticModelPublisher
@@ -203,6 +205,8 @@ class ItemPublisher(Publisher):
ItemType.ONTOLOGY: OntologyPublisher,
ItemType.MAP: MapPublisher,
ItemType.GRAPH_MODEL: GraphModelPublisher,
ItemType.ORG_APP: OrgAppPublisher,
ItemType.ORG_APP_AUDIENCE: OrgAppAudiencePublisher,
}
publisher_class = publisher_mapping.get(item_type)
+20
View File
@@ -0,0 +1,20 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
"""Functions to process and deploy Org App item."""
from fabric_cicd._common._item import Item
from fabric_cicd._items._base_publisher import ItemPublisher
from fabric_cicd.constants import EXCLUDE_PATH_REGEX_MAPPING, ItemType
class OrgAppPublisher(ItemPublisher):
"""Publisher for Org App items."""
item_type = ItemType.ORG_APP.value
def publish_one(self, item_name: str, _item: Item) -> None:
"""Publish the app definition without separately deployed child audiences."""
self.fabric_workspace_obj._publish_item(
item_name=item_name, item_type=self.item_type, exclude_path=EXCLUDE_PATH_REGEX_MAPPING.get(self.item_type)
)
+13
View File
@@ -0,0 +1,13 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
"""Functions to process and deploy Org App Audience item."""
from fabric_cicd._items._base_publisher import ItemPublisher
from fabric_cicd.constants import ItemType
class OrgAppAudiencePublisher(ItemPublisher):
"""Publisher for Org App Audience items."""
item_type = ItemType.ORG_APP_AUDIENCE.value
+12
View File
@@ -68,6 +68,8 @@ class ItemType(str, Enum):
MOUNTED_DATA_FACTORY = "MountedDataFactory"
NOTEBOOK = "Notebook"
ONTOLOGY = "Ontology"
ORG_APP = "OrgApp"
ORG_APP_AUDIENCE = "OrgAppAudience"
PAGINATED_REPORT = "PaginatedReport"
REFLEX = "Reflex"
REPORT = "Report"
@@ -113,6 +115,8 @@ SERIAL_ITEM_PUBLISH_ORDER: dict[int, ItemType] = {
29: ItemType.ML_EXPERIMENT,
30: ItemType.MAP,
31: ItemType.GRAPH_MODEL,
32: ItemType.ORG_APP,
33: ItemType.ORG_APP_AUDIENCE,
}
@@ -226,6 +230,12 @@ SENSITIVE_RESPONSE_HEADERS = frozenset({
})
# Publish
# Item types whose folder location is managed by a parent and cannot move independently
NON_MOVABLE_ITEM_TYPES = [
ItemType.ORG_APP_AUDIENCE.value,
]
SHELL_ONLY_PUBLISH = [
ItemType.LAKEHOUSE.value,
ItemType.WAREHOUSE.value,
@@ -245,6 +255,7 @@ EXCLUDE_PATH_REGEX_MAPPING = {
ItemType.REPORT.value: r".*\.pbi[/\\].*",
ItemType.SEMANTIC_MODEL.value: r".*\.pbi[/\\].*",
ItemType.EVENTHOUSE.value: r".*\.children[/\\].*",
ItemType.ORG_APP.value: r".*\.children[/\\].*",
}
# API Format Mapping for item types that require specific API formats
@@ -265,6 +276,7 @@ DATAFLOW_SOURCE_REGEX = (
)
INVALID_FOLDER_CHAR_REGEX = r'[~"#.%&*:<>?/\\{|}]'
KQL_DATABASE_FOLDER_PATH_REGEX = r"(?i)^(.*)/[^/]+\.Eventhouse/\.children(?:/.*)?$"
ORG_APP_AUDIENCE_FOLDER_PATH_REGEX = r"(?i)^(.*)/[^/]+\.OrgApp/\.children(?:/.*)?$"
DYNAMIC_VARIABLES_REGEX = r"^\$(workspace|items)\."
# Well known file names
+21 -3
View File
@@ -15,8 +15,17 @@ import dpath
from azure.core.credentials import TokenCredential
from fabric_cicd import constants
from fabric_cicd._common._check_utils import check_regex, check_valid_json_content, check_valid_yaml_content
from fabric_cicd._common._exceptions import FailedPublishedItemStatusError, InputError, ParameterFileError, ParsingError
from fabric_cicd._common._check_utils import (
check_regex,
check_valid_json_content,
check_valid_yaml_content,
)
from fabric_cicd._common._exceptions import (
FailedPublishedItemStatusError,
InputError,
ParameterFileError,
ParsingError,
)
from fabric_cicd._common._fabric_endpoint import FabricEndpoint, handle_retry
from fabric_cicd._common._item import Item
from fabric_cicd._common._logging import log_header
@@ -467,6 +476,10 @@ class FabricWorkspace:
pattern = re.compile(constants.KQL_DATABASE_FOLDER_PATH_REGEX)
match = pattern.match(relative_path)
relative_parent_path = match.group(1) if match else None
elif item_type == ItemType.ORG_APP_AUDIENCE.value:
# Set the OrgAppAudience to the same workspace folder as its parent OrgApp.
match = re.match(constants.ORG_APP_AUDIENCE_FOLDER_PATH_REGEX, relative_path)
relative_parent_path = match.group(1) if match else None
else:
relative_parent_path = "/".join(relative_path.split("/")[:-1])
@@ -857,7 +870,12 @@ class FabricWorkspace:
)
api_response = metadata_update_response
if FeatureFlag.DISABLE_WORKSPACE_FOLDER_PUBLISH.value not in constants.FEATURE_FLAG:
# Parent-managed items cannot move independently, even if their cached
# folder differs after a parent move.
if (
FeatureFlag.DISABLE_WORKSPACE_FOLDER_PUBLISH.value not in constants.FEATURE_FLAG
and item_type not in constants.NON_MOVABLE_ITEM_TYPES
):
deployed_item = self.deployed_items.get(item_type, {}).get(item_name) if is_deployed else None
# Check if the folder has changed
if deployed_item is not None and deployed_item.folder_id != item.folder_id:
+149 -1
View File
@@ -3,6 +3,7 @@
"""Test publishing functionality including selective publishing based on repository content."""
import base64
import json
import logging
import tempfile
@@ -16,7 +17,10 @@ from fixtures.credentials import DummyTokenCredential
import fabric_cicd.publish as publish
from fabric_cicd import constants
from fabric_cicd._common._exceptions import InputError
from fabric_cicd._items._base_publisher import ItemPublisher
from fabric_cicd._items._notebook import NotebookPublisher
from fabric_cicd._items._orgapp import OrgAppPublisher
from fabric_cicd._items._orgappaudience import OrgAppAudiencePublisher
from fabric_cicd._items._paginatedreport import PaginatedReportPublisher
from fabric_cicd.constants import API_FORMAT_MAPPING, ItemType
from fabric_cicd.fabric_workspace import FabricWorkspace
@@ -107,6 +111,143 @@ def create_test_item(base_path: Path, folder: Optional[str], name: str, item_typ
# =============================================================================
@pytest.mark.parametrize(
("item_type", "publisher_class"),
[(ItemType.ORG_APP, OrgAppPublisher), (ItemType.ORG_APP_AUDIENCE, OrgAppAudiencePublisher)],
)
def test_org_app_publisher_uses_default_definition(item_type, publisher_class):
"""Both publishers use the generic definition pipeline, excluding app children."""
workspace = MagicMock()
publisher = ItemPublisher.create(item_type, workspace)
assert isinstance(publisher, publisher_class)
publisher.publish_one("TestItem", MagicMock())
expected_kwargs = {"item_name": "TestItem", "item_type": item_type.value}
if item_type == ItemType.ORG_APP:
expected_kwargs["exclude_path"] = constants.EXCLUDE_PATH_REGEX_MAPPING[item_type.value]
else:
assert item_type.value not in constants.EXCLUDE_PATH_REGEX_MAPPING
workspace._publish_item.assert_called_once_with(**expected_kwargs)
assert item_type.value not in constants.SHELL_ONLY_PUBLISH
assert item_type.value not in constants.API_FORMAT_MAPPING
assert not publisher.has_dependency_tracking
def test_publish_org_app_with_nested_audience(mock_endpoint, temp_workspace_dir):
"""Child definitions stay out of the app payload and resolve the deployed parent ID."""
app_logical_id = "11111111-1111-1111-1111-111111111111"
app_dir = create_test_item(temp_workspace_dir, None, "TestApp", "OrgApp", app_logical_id)
audience_dir = create_test_item(
app_dir, ".children", "TestAudience", "OrgAppAudience", "22222222-2222-2222-2222-222222222222"
)
(app_dir / "definition.json").write_text(json.dumps({"elements": []}), encoding="utf-8")
(audience_dir / "definition.json").write_text(
json.dumps({"parentAppId": app_logical_id, "elementReferences": []}), encoding="utf-8"
)
with (
patch("fabric_cicd.fabric_workspace.FabricEndpoint", return_value=mock_endpoint),
patch.object(FabricWorkspace, "_refresh_deployed_items", new=lambda self: setattr(self, "deployed_items", {})),
patch.object(
FabricWorkspace, "_refresh_deployed_folders", new=lambda self: setattr(self, "deployed_folders", {})
),
):
workspace = FabricWorkspace(
workspace_id="12345678-1234-5678-abcd-1234567890ab",
repository_directory=str(temp_workspace_dir),
item_type_in_scope=["OrgApp", "OrgAppAudience"],
token_credential=DummyTokenCredential(),
)
publish.publish_all_items(workspace)
bodies = [
call.kwargs["body"]
for call in mock_endpoint.invoke.call_args_list
if call.kwargs.get("method") == "POST" and call.kwargs.get("url", "").endswith("/items")
]
assert [body["type"] for body in bodies] == ["OrgApp", "OrgAppAudience"]
app_parts = bodies[0]["definition"]["parts"]
assert all(".children" not in part["path"] for part in app_parts)
assert "definition.json" in {part["path"] for part in app_parts}
audience_definition = next(
part for part in bodies[1]["definition"]["parts"] if part["path"] == "definition.json"
)
assert json.loads(base64.b64decode(audience_definition["payload"]))["parentAppId"] == "mock-item-id"
def test_publish_org_app_before_audience(mock_endpoint, temp_workspace_dir):
"""Repository discovery and publishing place apps before audiences at the end."""
create_test_item(temp_workspace_dir, None, "TestApp", "OrgApp", "11111111-1111-1111-1111-111111111111")
create_test_item(temp_workspace_dir, None, "TestAudience", "OrgAppAudience", "22222222-2222-2222-2222-222222222222")
with (
patch("fabric_cicd.fabric_workspace.FabricEndpoint", return_value=mock_endpoint),
patch.object(FabricWorkspace, "_refresh_deployed_items", new=lambda self: setattr(self, "deployed_items", {})),
patch.object(
FabricWorkspace, "_refresh_deployed_folders", new=lambda self: setattr(self, "deployed_folders", {})
),
patch.object(FabricWorkspace, "_publish_item") as mock_publish,
):
workspace = FabricWorkspace(
workspace_id="12345678-1234-5678-abcd-1234567890ab",
repository_directory=str(temp_workspace_dir),
item_type_in_scope=["OrgAppAudience", "OrgApp"],
token_credential=DummyTokenCredential(),
)
publish.publish_all_items(workspace)
assert set(workspace.repository_items) == {"OrgApp", "OrgAppAudience"}
assert [call.kwargs for call in mock_publish.call_args_list] == [
{
"item_name": "TestApp",
"item_type": "OrgApp",
"exclude_path": constants.EXCLUDE_PATH_REGEX_MAPPING["OrgApp"],
},
{"item_name": "TestAudience", "item_type": "OrgAppAudience"},
]
assert list(constants.SERIAL_ITEM_PUBLISH_ORDER.values())[-2:] == [
ItemType.ORG_APP,
ItemType.ORG_APP_AUDIENCE,
]
def test_unpublish_org_app_audience_before_app(mock_endpoint, temp_workspace_dir):
"""Orphan audiences are deleted before their apps without feature flags."""
deployed_items = {
"OrgApp": {"TestApp": MagicMock(guid="11111111-1111-1111-1111-111111111111")},
"OrgAppAudience": {"TestAudience": MagicMock(guid="22222222-2222-2222-2222-222222222222")},
}
with (
patch("fabric_cicd.fabric_workspace.FabricEndpoint", return_value=mock_endpoint),
patch.object(
FabricWorkspace, "_refresh_deployed_items", new=lambda self: setattr(self, "deployed_items", deployed_items)
),
patch.object(
FabricWorkspace, "_refresh_deployed_folders", new=lambda self: setattr(self, "deployed_folders", {})
),
patch.object(FabricWorkspace, "_unpublish_folders"),
patch.object(FabricWorkspace, "_unpublish_item") as mock_unpublish,
patch.object(constants, "FEATURE_FLAG", set()),
):
workspace = FabricWorkspace(
workspace_id="12345678-1234-5678-abcd-1234567890ab",
repository_directory=str(temp_workspace_dir),
item_type_in_scope=["OrgApp", "OrgAppAudience"],
token_credential=DummyTokenCredential(),
)
publish.unpublish_all_orphan_items(workspace)
assert [call.kwargs for call in mock_unpublish.call_args_list] == [
{"item_name": "TestAudience", "item_type": "OrgAppAudience"},
{"item_name": "TestApp", "item_type": "OrgApp"},
]
def test_publish_only_existing_item_types(mock_endpoint, temp_workspace_dir):
"""Test that publish_all_items only attempts to publish item types that exist in repository."""
create_test_item(temp_workspace_dir, None, "TestNotebook", "Notebook", "test-notebook-id")
@@ -488,7 +629,14 @@ def test_unpublish_feature_flag_warnings(mock_endpoint, temp_workspace_dir, capl
workspace = FabricWorkspace(
workspace_id="12345678-1234-5678-abcd-1234567890ab",
repository_directory=str(temp_workspace_dir),
item_type_in_scope=["Lakehouse", "Warehouse", "SQLDatabase", "CosmosDBDatabase", "Eventhouse", "GraphModel"],
item_type_in_scope=[
"Lakehouse",
"Warehouse",
"SQLDatabase",
"CosmosDBDatabase",
"Eventhouse",
"GraphModel",
],
token_credential=DummyTokenCredential(),
)