From 692e749bfabb0ea4169873bc6a235f51df7d420a Mon Sep 17 00:00:00 2001 From: wvandeun Date: Sat, 15 Nov 2025 00:05:05 +0100 Subject: [PATCH 01/10] add optional graphql variable types and add datetime ruff --- infrahub_sdk/graphql/constants.py | 15 ++++++++++++++- infrahub_sdk/graphql/renderers.py | 17 ++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/infrahub_sdk/graphql/constants.py b/infrahub_sdk/graphql/constants.py index cd20fd4d..e2033155 100644 --- a/infrahub_sdk/graphql/constants.py +++ b/infrahub_sdk/graphql/constants.py @@ -1 +1,14 @@ -VARIABLE_TYPE_MAPPING = ((str, "String!"), (int, "Int!"), (float, "Float!"), (bool, "Boolean!")) +from datetime import datetime + +VARIABLE_TYPE_MAPPING = ( + (str, "String!"), + (str | None, "String"), + (int, "Int!"), + (int | None, "Int"), + (float, "Float!"), + (float | None, "Float"), + (bool, "Boolean!"), + (bool | None, "Boolean"), + (datetime, "DateTime!"), + (datetime | None, "DateTime"), +) diff --git a/infrahub_sdk/graphql/renderers.py b/infrahub_sdk/graphql/renderers.py index 1b757949..afc8f9c7 100644 --- a/infrahub_sdk/graphql/renderers.py +++ b/infrahub_sdk/graphql/renderers.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from datetime import datetime from enum import Enum from typing import Any @@ -66,7 +67,21 @@ def convert_to_graphql_as_string(value: Any, convert_enum: bool = False) -> str: return str(value) -def render_variables_to_string(data: dict[str, type[str | int | float | bool]]) -> str: +GRAPHQL_VARIABLE_TYPES = type[ + str + | type[str | None] + | int + | type[int | None] + | float + | type[float | None] + | bool + | type[bool | None] + | datetime + | type[datetime | None] +] + + +def render_variables_to_string(data: dict[str, GRAPHQL_VARIABLE_TYPES]) -> str: """Render a dict into a variable string that will be used in a GraphQL Query. The $ sign will be automatically added to the name of the query. From 0b20f0eba0aebad81a1d35b51f0390e4ec1857a9 Mon Sep 17 00:00:00 2001 From: wvandeun Date: Sat, 15 Nov 2025 00:05:43 +0100 Subject: [PATCH 02/10] add get_diff_tree method to SDK client to get a diff object --- infrahub_sdk/client.py | 118 +++++++++++++- infrahub_sdk/diff.py | 79 +++++++++ tests/unit/sdk/test_diff_summary.py | 245 ++++++++++++++++++++++++++++ 3 files changed, 441 insertions(+), 1 deletion(-) diff --git a/infrahub_sdk/client.py b/infrahub_sdk/client.py index d7b79047..aa0ca7db 100644 --- a/infrahub_sdk/client.py +++ b/infrahub_sdk/client.py @@ -35,7 +35,7 @@ from .constants import InfrahubClientMode from .convert_object_type import CONVERT_OBJECT_MUTATION, ConversionFieldInput from .data import RepositoryBranchInfo, RepositoryData -from .diff import NodeDiff, diff_tree_node_to_node_diff, get_diff_summary_query +from .diff import DiffTreeData, NodeDiff, diff_tree_node_to_node_diff, get_diff_summary_query, get_diff_tree_query from .exceptions import ( AuthenticationError, Error, @@ -1283,6 +1283,64 @@ async def get_diff_summary( return node_diffs + async def get_diff_tree( + self, + branch: str, + name: str | None = None, + from_time: datetime | None = None, + to_time: datetime | None = None, + timeout: int | None = None, + tracker: str | None = None, + raise_for_error: bool | None = None, + ) -> DiffTreeData | None: + """Get complete diff tree with metadata and nodes. + + Returns None if no diff exists. + """ + query = get_diff_tree_query() + input_data = {"branch_name": branch} + if name: + input_data["name"] = name + if from_time and to_time and from_time > to_time: + raise ValueError("from_time must be <= to_time") + if from_time: + input_data["from_time"] = from_time.isoformat() + if to_time: + input_data["to_time"] = to_time.isoformat() + + response = await self.execute_graphql( + query=query.render(), + branch_name=branch, + timeout=timeout, + tracker=tracker, + raise_for_error=raise_for_error, + variables=input_data, + ) + + diff_tree = response["DiffTree"] + if diff_tree is None: + return None + + # Convert nodes to NodeDiff objects + node_diffs: list[NodeDiff] = [] + if "nodes" in diff_tree: + for node_dict in diff_tree["nodes"]: + node_diff = diff_tree_node_to_node_diff(node_dict=node_dict, branch_name=branch) + node_diffs.append(node_diff) + + return DiffTreeData( + num_added=diff_tree.get("num_added") or 0, + num_updated=diff_tree.get("num_updated") or 0, + num_removed=diff_tree.get("num_removed") or 0, + num_conflicts=diff_tree.get("num_conflicts") or 0, + to_time=diff_tree["to_time"], + from_time=diff_tree["from_time"], + base_branch=diff_tree["base_branch"], + diff_branch=diff_tree["diff_branch"], + name=diff_tree.get("name"), + nodes=node_diffs, + ) + @overload async def allocate_next_ip_address( self, @@ -2521,6 +2579,64 @@ def get_diff_summary( return node_diffs + def get_diff_tree( + self, + branch: str, + name: str | None = None, + from_time: datetime | None = None, + to_time: datetime | None = None, + timeout: int | None = None, + tracker: str | None = None, + raise_for_error: bool | None = None, + ) -> DiffTreeData | None: + """Get complete diff tree with metadata and nodes. + + Returns None if no diff exists. + """ + query = get_diff_tree_query() + input_data = {"branch_name": branch} + if name: + input_data["name"] = name + if from_time and to_time and from_time > to_time: + raise ValueError("from_time must be <= to_time") + if from_time: + input_data["from_time"] = from_time.isoformat() + if to_time: + input_data["to_time"] = to_time.isoformat() + + response = self.execute_graphql( + query=query.render(), + branch_name=branch, + timeout=timeout, + tracker=tracker, + raise_for_error=raise_for_error, + variables=input_data, + ) + + diff_tree = response["DiffTree"] + if diff_tree is None: + return None + + # Convert nodes to NodeDiff objects + node_diffs: list[NodeDiff] = [] + if "nodes" in diff_tree: + for node_dict in diff_tree["nodes"]: + node_diff = diff_tree_node_to_node_diff(node_dict=node_dict, branch_name=branch) + node_diffs.append(node_diff) + + return DiffTreeData( + num_added=diff_tree.get("num_added") or 0, + num_updated=diff_tree.get("num_updated") or 0, + num_removed=diff_tree.get("num_removed") or 0, + num_conflicts=diff_tree.get("num_conflicts") or 0, + to_time=diff_tree["to_time"], + from_time=diff_tree["from_time"], + base_branch=diff_tree["base_branch"], + diff_branch=diff_tree["diff_branch"], + name=diff_tree.get("name"), + nodes=node_diffs, + ) + @overload def allocate_next_ip_address( self, diff --git a/infrahub_sdk/diff.py b/infrahub_sdk/diff.py index fad10080..e8924736 100644 --- a/infrahub_sdk/diff.py +++ b/infrahub_sdk/diff.py @@ -1,11 +1,14 @@ from __future__ import annotations +from datetime import datetime from typing import ( Any, ) from typing_extensions import NotRequired, TypedDict +from infrahub_sdk.graphql.query import Query + class NodeDiff(TypedDict): branch: str @@ -35,6 +38,19 @@ class NodeDiffPeer(TypedDict): summary: NodeDiffSummary +class DiffTreeData(TypedDict): + num_added: int + num_updated: int + num_removed: int + num_conflicts: int + to_time: str + from_time: str + base_branch: str + diff_branch: str + name: NotRequired[str | None] + nodes: list[NodeDiff] + + def get_diff_summary_query() -> str: return """ query GetDiffTree($branch_name: String!, $name: String, $from_time: DateTime, $to_time: DateTime) { @@ -125,3 +141,66 @@ def diff_tree_node_to_node_diff(node_dict: dict[str, Any], branch_name: str) -> display_label=str(node_dict.get("label")), elements=element_diffs, ) + + +def get_diff_tree_query() -> Query: + node_structure = { + "uuid": None, + "kind": None, + "status": None, + "label": None, + "num_added": None, + "num_updated": None, + "num_removed": None, + "attributes": { + "name": None, + "status": None, + "num_added": None, + "num_updated": None, + "num_removed": None, + }, + "relationships": { + "name": None, + "status": None, + "cardinality": None, + "num_added": None, + "num_updated": None, + "num_removed": None, + "elements": { + "status": None, + "num_added": None, + "num_updated": None, + "num_removed": None, + }, + }, + } + + return Query( + name="GetDiffTree", + query={ + "DiffTree": { + "@filters": { + "branch": "$branch_name", + "name": "$name", + "from_time": "$from_time", + "to_time": "$to_time", + }, + "name": None, + "to_time": None, + "from_time": None, + "base_branch": None, + "diff_branch": None, + "num_added": None, + "num_updated": None, + "num_removed": None, + "num_conflicts": None, + "nodes": node_structure, + }, + }, + variables={ + "branch_name": str, + "name": str | None, + "from_time": datetime | None, + "to_time": datetime | None, + }, + ) diff --git a/tests/unit/sdk/test_diff_summary.py b/tests/unit/sdk/test_diff_summary.py index 73832cab..d0c4e461 100644 --- a/tests/unit/sdk/test_diff_summary.py +++ b/tests/unit/sdk/test_diff_summary.py @@ -1,7 +1,10 @@ +from datetime import datetime, timezone + import pytest from pytest_httpx import HTTPXMock from infrahub_sdk import InfrahubClient +from infrahub_sdk.diff import get_diff_tree_query from tests.unit.sdk.conftest import BothClients client_types = ["standard", "sync"] @@ -158,3 +161,245 @@ async def test_diffsummary(clients: BothClients, mock_diff_tree_query, client_ty }, ], } in node_diffs + + +def test_get_diff_tree_query_structure() -> None: + """Test that get_diff_tree_query returns proper Query object.""" + query = get_diff_tree_query() + + # Verify it's a Query object + assert hasattr(query, "render") + assert hasattr(query, "name") + + # Verify query name + assert query.name == "GetDiffTree" + + # Render and verify structure + rendered = query.render() + assert "query GetDiffTree" in rendered + assert "$branch_name: String!" in rendered + assert "$name: String" in rendered + assert "$from_time: DateTime" in rendered + assert "$to_time: DateTime" in rendered + assert "DiffTree" in rendered + assert "num_added" in rendered + assert "num_updated" in rendered + assert "num_removed" in rendered + assert "num_conflicts" in rendered + assert "to_time" in rendered + assert "from_time" in rendered + assert "base_branch" in rendered + assert "diff_branch" in rendered + assert "nodes" in rendered + + +@pytest.fixture +async def mock_diff_tree_with_metadata(httpx_mock: HTTPXMock, client: InfrahubClient) -> HTTPXMock: + """Mock diff tree response with complete metadata.""" + response = { + "data": { + "DiffTree": { + "num_added": 10, + "num_updated": 5, + "num_removed": 2, + "num_conflicts": 0, + "to_time": "2025-11-14T12:00:00Z", + "from_time": "2025-11-01T00:00:00Z", + "base_branch": "main", + "diff_branch": "feature-branch", + "name": "my-diff", + "nodes": [ + { + "attributes": [ + {"name": "name", "num_added": 0, "num_removed": 0, "num_updated": 1, "status": "UPDATED"} + ], + "kind": "TestPerson", + "label": "John", + "num_added": 0, + "num_removed": 0, + "num_updated": 1, + "relationships": [], + "status": "UPDATED", + "uuid": "17fbadf0-1111-1111-1111-111111111111", + }, + { + "attributes": [], + "kind": "TestDevice", + "label": "Router1", + "num_added": 1, + "num_removed": 0, + "num_updated": 0, + "relationships": [], + "status": "ADDED", + "uuid": "17fbadf0-2222-2222-2222-222222222222", + }, + ], + } + } + } + + httpx_mock.add_response( + method="POST", + json=response, + match_headers={"X-Infrahub-Tracker": "query-difftree-metadata"}, + ) + return httpx_mock + + +@pytest.mark.parametrize("client_type", client_types) +async def test_get_diff_tree(clients: BothClients, mock_diff_tree_with_metadata, client_type) -> None: + """Test get_diff_tree returns complete DiffTreeData with metadata.""" + if client_type == "standard": + diff_tree = await clients.standard.get_diff_tree( + branch="feature-branch", + tracker="query-difftree-metadata", + ) + else: + diff_tree = clients.sync.get_diff_tree( + branch="feature-branch", + tracker="query-difftree-metadata", + ) + + # Verify diff_tree is not None + assert diff_tree is not None + + # Verify metadata + assert diff_tree["num_added"] == 10 + assert diff_tree["num_updated"] == 5 + assert diff_tree["num_removed"] == 2 + assert diff_tree["num_conflicts"] == 0 + assert diff_tree["to_time"] == "2025-11-14T12:00:00Z" + assert diff_tree["from_time"] == "2025-11-01T00:00:00Z" + assert diff_tree["base_branch"] == "main" + assert diff_tree["diff_branch"] == "feature-branch" + assert diff_tree["name"] == "my-diff" + + # Verify nodes + assert len(diff_tree["nodes"]) == 2 + + # Verify first node + assert diff_tree["nodes"][0]["branch"] == "feature-branch" + assert diff_tree["nodes"][0]["kind"] == "TestPerson" + assert diff_tree["nodes"][0]["id"] == "17fbadf0-1111-1111-1111-111111111111" + assert diff_tree["nodes"][0]["action"] == "UPDATED" + assert diff_tree["nodes"][0]["display_label"] == "John" + assert len(diff_tree["nodes"][0]["elements"]) == 1 + + # Verify second node + assert diff_tree["nodes"][1]["kind"] == "TestDevice" + assert diff_tree["nodes"][1]["action"] == "ADDED" + + +@pytest.fixture +async def mock_diff_tree_none(httpx_mock: HTTPXMock, client: InfrahubClient) -> HTTPXMock: + """Mock diff tree response when no diff exists.""" + response = { + "data": { + "DiffTree": None + } + } + + httpx_mock.add_response( + method="POST", + json=response, + match_headers={"X-Infrahub-Tracker": "query-difftree-none"}, + ) + return httpx_mock + + +@pytest.mark.parametrize("client_type", client_types) +async def test_get_diff_tree_none(clients: BothClients, mock_diff_tree_none, client_type) -> None: + """Test get_diff_tree returns None when no diff exists.""" + if client_type == "standard": + diff_tree = await clients.standard.get_diff_tree( + branch="no-diff-branch", + tracker="query-difftree-none", + ) + else: + diff_tree = clients.sync.get_diff_tree( + branch="no-diff-branch", + tracker="query-difftree-none", + ) + + assert diff_tree is None + + +@pytest.fixture +async def mock_diff_tree_with_params(httpx_mock: HTTPXMock, client: InfrahubClient) -> HTTPXMock: + """Mock diff tree response for testing with name and time parameters.""" + response = { + "data": { + "DiffTree": { + "num_added": 3, + "num_updated": 1, + "num_removed": 0, + "num_conflicts": 0, + "to_time": "2025-11-14T18:00:00Z", + "from_time": "2025-11-14T12:00:00Z", + "base_branch": "main", + "diff_branch": "test-branch", + "name": "named-diff", + "nodes": [], + } + } + } + + httpx_mock.add_response( + method="POST", + json=response, + match_headers={"X-Infrahub-Tracker": "query-difftree-params"}, + ) + return httpx_mock + + +@pytest.mark.parametrize("client_type", client_types) +async def test_get_diff_tree_with_parameters( + clients: BothClients, mock_diff_tree_with_params, client_type +) -> None: + """Test get_diff_tree with name and time range parameters.""" + from_time = datetime(2025, 11, 14, 12, 0, 0, tzinfo=timezone.utc) + to_time = datetime(2025, 11, 14, 18, 0, 0, tzinfo=timezone.utc) + + if client_type == "standard": + diff_tree = await clients.standard.get_diff_tree( + branch="test-branch", + name="named-diff", + from_time=from_time, + to_time=to_time, + tracker="query-difftree-params", + ) + else: + diff_tree = clients.sync.get_diff_tree( + branch="test-branch", + name="named-diff", + from_time=from_time, + to_time=to_time, + tracker="query-difftree-params", + ) + + assert diff_tree is not None + assert diff_tree["name"] == "named-diff" + assert diff_tree["to_time"] == "2025-11-14T18:00:00Z" + assert diff_tree["from_time"] == "2025-11-14T12:00:00Z" + assert diff_tree["num_added"] == 3 + + +@pytest.mark.parametrize("client_type", client_types) +async def test_get_diff_tree_time_validation(clients: BothClients, client_type) -> None: + """Test get_diff_tree raises error when from_time > to_time.""" + from_time = datetime(2025, 11, 14, 18, 0, 0, tzinfo=timezone.utc) + to_time = datetime(2025, 11, 14, 12, 0, 0, tzinfo=timezone.utc) # Earlier than from_time + + with pytest.raises(ValueError, match="from_time must be <= to_time"): + if client_type == "standard": + await clients.standard.get_diff_tree( + branch="test-branch", + from_time=from_time, + to_time=to_time, + ) + else: + clients.sync.get_diff_tree( + branch="test-branch", + from_time=from_time, + to_time=to_time, + ) From 10e1905144f3ebd46acc5a02ea5160dfe35b57d8 Mon Sep 17 00:00:00 2001 From: wvandeun Date: Sun, 16 Nov 2025 15:45:06 -0500 Subject: [PATCH 03/10] add updated_at meta data property for attributes and relationships --- infrahub_sdk/node/attribute.py | 2 +- infrahub_sdk/node/related_node.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/infrahub_sdk/node/attribute.py b/infrahub_sdk/node/attribute.py index 1d97e0b0..bee9a2f7 100644 --- a/infrahub_sdk/node/attribute.py +++ b/infrahub_sdk/node/attribute.py @@ -105,7 +105,7 @@ def _generate_input_data(self) -> dict | None: return {"data": data, "variables": variables} def _generate_query_data(self, property: bool = False) -> dict | None: - data: dict[str, Any] = {"value": None} + data: dict[str, Any] = {"value": None, "updated_at": None} if property: data.update({"is_default": None, "is_from_profile": None}) diff --git a/infrahub_sdk/node/related_node.py b/infrahub_sdk/node/related_node.py index ab6cf17f..d53ff397 100644 --- a/infrahub_sdk/node/related_node.py +++ b/infrahub_sdk/node/related_node.py @@ -64,7 +64,7 @@ def __init__(self, branch: str, schema: RelationshipSchemaAPI, data: Any | dict, self._display_label = node_data.get("display_label", None) self._typename = node_data.get("__typename", None) - self.updated_at: str | None = data.get("updated_at", data.get("_relation__updated_at", None)) + self.updated_at: str | None = data.get("updated_at", properties_data.get("updated_at", None)) # FIXME, we won't need that once we are only supporting paginated results if self._typename and self._typename.startswith("Related"): @@ -163,7 +163,7 @@ def _generate_query_data(cls, peer_data: dict[str, Any] | None = None, property: and typename. The method also includes additional properties and any peer_data provided. """ data: dict[str, Any] = {"node": {"id": None, "hfid": None, "display_label": None, "__typename": None}} - properties: dict[str, Any] = {} + properties: dict[str, Any] = {"updated_at": None} if property: for prop_name in PROPERTIES_FLAG: From f9064f0902585fa8d030c13058b473aa62544e3b Mon Sep 17 00:00:00 2001 From: wvandeun Date: Sun, 16 Nov 2025 16:28:26 -0500 Subject: [PATCH 04/10] add branch report command to infrahubctl --- infrahub_sdk/ctl/branch.py | 151 ++++++++++++++++++++++++++++++++++++- 1 file changed, 150 insertions(+), 1 deletion(-) diff --git a/infrahub_sdk/ctl/branch.py b/infrahub_sdk/ctl/branch.py index 42d88384..2f97e742 100644 --- a/infrahub_sdk/ctl/branch.py +++ b/infrahub_sdk/ctl/branch.py @@ -1,15 +1,20 @@ import logging +from datetime import datetime, timezone +from typing import TYPE_CHECKING import typer from rich.console import Console from rich.table import Table from ..async_typer import AsyncTyper -from ..utils import calculate_time_diff +from ..utils import calculate_time_diff, decode_json from .client import initialize_client from .parameters import CONFIG_PARAM from .utils import catch_exception +if TYPE_CHECKING: + from ..client import InfrahubClient + app = AsyncTyper() console = Console() @@ -18,6 +23,42 @@ ENVVAR_CONFIG_FILE = "INFRAHUBCTL_CONFIG" +def format_timestamp(timestamp: str) -> str: + """Format ISO timestamp to 'YYYY-MM-DD HH:MM:SS'.""" + try: + dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + return dt.strftime("%Y-%m-%d %H:%M:%S") + except (ValueError, AttributeError): + return timestamp + + +async def check_git_files_changed(client: "InfrahubClient", branch: str) -> bool: + """Check if there are any Git file changes in a branch. + + Args: + client: Infrahub client instance + branch: Branch name to check + + Returns: + True if files have changed, False otherwise + + Raises: + Any exceptions from the API call are propagated to the caller + """ + url = f"{client.address}/api/diff/files?branch={branch}" + resp = await client._get(url=url, timeout=client.default_timeout) + resp.raise_for_status() + data = decode_json(response=resp) + + # Check if any repository has files + if branch in data: + for repo_data in data[branch].values(): + if isinstance(repo_data, dict) and "files" in repo_data and len(repo_data["files"]) > 0: + return True + + return False + + @app.callback() def callback() -> None: """ @@ -143,3 +184,111 @@ async def validate(branch_name: str, _: str = CONFIG_PARAM) -> None: client = initialize_client() await client.branch.validate(branch_name=branch_name) console.print(f"Branch '{branch_name}' is valid.") + + +@app.command() +@catch_exception(console=console) +async def report( # noqa: PLR0915 + branch_name: str = typer.Argument(..., help="Branch name to generate report for"), + update_diff: bool = typer.Option(False, "--update-diff", help="Update diff before generating report"), + _: str = CONFIG_PARAM, +) -> None: + """Generate branch cleanup status report.""" + + client = initialize_client() + + # Fetch branch metadata first (needed for diff creation) + branch = await client.branch.get(branch_name=branch_name) + + # Update diff if requested + if update_diff: + console.print("Updating diff...") + # Create diff from branch creation to now + from_time = datetime.fromisoformat(branch.branched_from.replace("Z", "+00:00")) + to_time = datetime.now(timezone.utc) + await client.create_diff( + branch=branch_name, + name=f"report-{branch_name}", + from_time=from_time, + to_time=to_time, + ) + console.print("Diff updated\n") + + # Fetch diff tree (with metadata) + diff_tree = await client.get_diff_tree(branch=branch_name) + + # Check if Git files have changed + git_files_changed = None + git_files_changed = await check_git_files_changed(client, branch=branch_name) + + # Print branch title + console.print() + console.print(f"[bold]Branch: {branch_name}[/bold]") + + # Create branch metadata table + branch_table = Table(show_header=False, box=None) + branch_table.add_column(justify="left") + branch_table.add_column(justify="right") + + # Add branch metadata rows + branch_table.add_row("Created at", format_timestamp(branch.branched_from)) + + # Add status + status_value = branch.status.value if hasattr(branch.status, "value") else str(branch.status) + branch_table.add_row("Status", status_value) + + branch_table.add_row("Synced with Git", "Yes" if branch.sync_with_git else "No") + + # Add Git files changed + if git_files_changed is not None: + branch_table.add_row("Git files changed", "Yes" if git_files_changed else "No") + else: + branch_table.add_row("Git files changed", "N/A") + + branch_table.add_row("Has schema changes", "Yes" if branch.has_schema_changes else "No") + + # Add diff information + if diff_tree: + branch_table.add_row("Diff last updated", format_timestamp(diff_tree["to_time"])) + branch_table.add_row("Amount of additions", str(diff_tree["num_added"])) + branch_table.add_row("Amount of deletions", str(diff_tree["num_removed"])) + branch_table.add_row("Amount of updates", str(diff_tree["num_updated"])) + branch_table.add_row("Amount of conflicts", str(diff_tree["num_conflicts"])) + else: + branch_table.add_row("Diff last updated", "No diff available") + branch_table.add_row("Amount of additions", "-") + branch_table.add_row("Amount of deletions", "-") + branch_table.add_row("Amount of updates", "-") + branch_table.add_row("Amount of conflicts", "-") + + console.print(branch_table) + console.print() + + # Fetch proposed changes for the branch + proposed_changes = await client.filters( + kind="CoreProposedChange", source_branch__value=branch_name, include=["created_by"], prefetch_relationships=True + ) + + # Print proposed changes section + if proposed_changes: + for pc in proposed_changes: + # Create proposal table + proposal_table = Table(show_header=False, box=None) + proposal_table.add_column(justify="left") + proposal_table.add_column(justify="right") + + # Extract data from node + proposal_table.add_row("Name", pc.name.value) # type: ignore[union-attr] + proposal_table.add_row("State", str(pc.state.value)) # type: ignore[union-attr] + proposal_table.add_row("Is draft", "Yes" if pc.is_draft.value else "No") # type: ignore[union-attr] + proposal_table.add_row("Created by", pc.created_by.peer.name.value) # type: ignore[union-attr] + proposal_table.add_row("Created at", format_timestamp(str(pc.created_by.updated_at))) # type: ignore[union-attr] + proposal_table.add_row("Approvals", str(len(pc.approved_by.peers))) # type: ignore[union-attr] + proposal_table.add_row("Rejections", str(len(pc.rejected_by.peers))) # type: ignore[union-attr] + + console.print(f"Proposed change: {pc.name.value}") # type: ignore[union-attr] + console.print(proposal_table) + console.print() + else: + console.print("No proposed changes for this branch") + console.print() From a3e06feece8ec91167d3e1f311df5d1c0cb71c52 Mon Sep 17 00:00:00 2001 From: wvandeun Date: Mon, 17 Nov 2025 11:44:05 -0600 Subject: [PATCH 05/10] fix --- infrahub_sdk/graphql/renderers.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/infrahub_sdk/graphql/renderers.py b/infrahub_sdk/graphql/renderers.py index afc8f9c7..3cd3e557 100644 --- a/infrahub_sdk/graphql/renderers.py +++ b/infrahub_sdk/graphql/renderers.py @@ -67,18 +67,7 @@ def convert_to_graphql_as_string(value: Any, convert_enum: bool = False) -> str: return str(value) -GRAPHQL_VARIABLE_TYPES = type[ - str - | type[str | None] - | int - | type[int | None] - | float - | type[float | None] - | bool - | type[bool | None] - | datetime - | type[datetime | None] -] +GRAPHQL_VARIABLE_TYPES = type[str | int | float | bool | datetime | None] def render_variables_to_string(data: dict[str, GRAPHQL_VARIABLE_TYPES]) -> str: From 86bc4b901f6eebac676df01654eae3e7043d8f46 Mon Sep 17 00:00:00 2001 From: wvandeun Date: Mon, 17 Nov 2025 11:57:32 -0600 Subject: [PATCH 06/10] add tests --- tests/unit/ctl/test_branch_report.py | 427 +++++++++++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 tests/unit/ctl/test_branch_report.py diff --git a/tests/unit/ctl/test_branch_report.py b/tests/unit/ctl/test_branch_report.py new file mode 100644 index 00000000..5473285b --- /dev/null +++ b/tests/unit/ctl/test_branch_report.py @@ -0,0 +1,427 @@ +import pytest +from httpx import HTTPStatusError +from pytest_httpx import HTTPXMock +from typer.testing import CliRunner + +from infrahub_sdk import Config, InfrahubClient +from infrahub_sdk.ctl.branch import app, check_git_files_changed, format_timestamp + +pytestmark = pytest.mark.httpx_mock(can_send_already_matched_responses=True) + + +def test_format_timestamp_valid_iso() -> None: + """Test format_timestamp with valid ISO timestamp.""" + timestamp = "2025-11-14T12:30:45Z" + result = format_timestamp(timestamp) + assert result == "2025-11-14 12:30:45" + + +def test_format_timestamp_with_timezone() -> None: + """Test format_timestamp with timezone offset.""" + timestamp = "2025-11-14T12:30:45+00:00" + result = format_timestamp(timestamp) + assert result == "2025-11-14 12:30:45" + + +def test_format_timestamp_invalid() -> None: + """Test format_timestamp with invalid timestamp returns original.""" + timestamp = "not-a-timestamp" + result = format_timestamp(timestamp) + assert result == "not-a-timestamp" + + +def test_format_timestamp_none() -> None: + """Test format_timestamp with None returns original.""" + timestamp = None + result = format_timestamp(timestamp) # type: ignore[arg-type] + assert result is None + + +@pytest.fixture +async def mock_git_files_changed_yes(httpx_mock: HTTPXMock) -> HTTPXMock: + """Mock REST API response with files changed.""" + response = { + "test-branch": { + "repo-id-1": { + "files": [ + {"path": "file1.py", "status": "modified"}, + {"path": "file2.py", "status": "added"}, + ] + } + } + } + + httpx_mock.add_response( + method="GET", + url="http://mock/api/diff/files?branch=test-branch", + json=response, + ) + return httpx_mock + + +@pytest.fixture +async def mock_git_files_changed_no(httpx_mock: HTTPXMock) -> HTTPXMock: + """Mock REST API response with no files changed.""" + response = {"test-branch": {"repo-id-1": {"files": []}}} + + httpx_mock.add_response( + method="GET", + url="http://mock/api/diff/files?branch=test-branch", + json=response, + ) + return httpx_mock + + +@pytest.fixture +async def mock_git_files_empty_response(httpx_mock: HTTPXMock) -> HTTPXMock: + """Mock REST API response with empty data.""" + response = {} + + httpx_mock.add_response( + method="GET", + url="http://mock/api/diff/files?branch=test-branch", + json=response, + ) + return httpx_mock + + +async def test_check_git_files_changed_with_files( + mock_git_files_changed_yes: HTTPXMock, +) -> None: + """Test check_git_files_changed returns True when files exist.""" + client = InfrahubClient(config=Config(address="http://mock")) + result = await check_git_files_changed(client, branch="test-branch") + assert result is True + + +async def test_check_git_files_changed_no_files( + mock_git_files_changed_no: HTTPXMock, +) -> None: + """Test check_git_files_changed returns False when no files.""" + client = InfrahubClient(config=Config(address="http://mock")) + result = await check_git_files_changed(client, branch="test-branch") + assert result is False + + +async def test_check_git_files_changed_empty_response( + mock_git_files_empty_response: HTTPXMock, +) -> None: + """Test check_git_files_changed returns False when branch not in response.""" + client = InfrahubClient(config=Config(address="http://mock")) + result = await check_git_files_changed(client, branch="test-branch") + assert result is False + + +async def test_check_git_files_changed_http_error(httpx_mock: HTTPXMock) -> None: + """Test check_git_files_changed raises exception on HTTP error.""" + httpx_mock.add_response( + method="GET", + url="http://mock/api/diff/files?branch=test-branch", + status_code=404, + ) + + client = InfrahubClient(config=Config(address="http://mock")) + with pytest.raises(HTTPStatusError): + await check_git_files_changed(client, branch="test-branch") + + +@pytest.fixture +def mock_branch_report_command(httpx_mock: HTTPXMock) -> HTTPXMock: + """Mock all responses for branch report CLI command.""" + httpx_mock.add_response( + method="POST", + json={ + "data": { + "Branch": [ + { + "id": "test-branch-id", + "name": "test-branch", + "sync_with_git": True, + "is_default": False, + "origin_branch": "main", + "branched_from": "2025-11-01T10:00:00Z", + "has_schema_changes": False, + "status": "OPEN", + } + ] + } + }, + match_headers={"X-Infrahub-Tracker": "query-branch"}, + ) + + httpx_mock.add_response( + method="POST", + json={ + "data": { + "DiffTree": { + "num_added": 5, + "num_updated": 3, + "num_removed": 1, + "num_conflicts": 0, + "to_time": "2025-11-14T18:00:00Z", + "from_time": "2025-11-01T10:00:00Z", + "base_branch": "main", + "diff_branch": "test-branch", + "name": None, + "nodes": [], + } + } + }, + ) + + httpx_mock.add_response( + method="GET", + json={"test-branch": {"repo-1": {"files": [{"path": "test.py"}]}}}, + ) + + httpx_mock.add_response( + method="POST", + json={"data": {"CoreProposedChange": {"count": 0, "edges": []}}}, + ) + + return httpx_mock + + +def test_branch_report_command_without_proposed_change( + mock_branch_report_command: HTTPXMock, + mock_schema_query_05, # type: ignore[misc] +) -> None: + """Test branch report CLI command with no proposed changes.""" + runner = CliRunner() + result = runner.invoke(app, ["report", "test-branch"]) + + assert result.exit_code == 0, f"Command failed: {result.stdout}" + assert "Branch: test-branch" in result.stdout + assert "2025-11-01 10:00:00" in result.stdout + assert "OPEN" in result.stdout + assert "Amount of additions" in result.stdout + assert "5" in result.stdout + assert "No proposed changes for this branch" in result.stdout + + +@pytest.fixture +async def schema_with_proposed_change() -> dict: + """Schema fixture that includes CoreProposedChange with is_draft.""" + return { + "nodes": [ + { + "name": "ProposedChange", + "namespace": "Core", + "default_filter": "name__value", + "attributes": [ + {"name": "name", "kind": "Text"}, + {"name": "state", "kind": "Text"}, + {"name": "is_draft", "kind": "Boolean"}, + ], + "relationships": [ + {"name": "created_by", "peer": "CoreAccount", "cardinality": "one"}, + {"name": "approved_by", "peer": "CoreAccount", "cardinality": "many"}, + {"name": "rejected_by", "peer": "CoreAccount", "cardinality": "many"}, + ], + }, + { + "name": "Account", + "namespace": "Core", + "default_filter": "name__value", + "attributes": [ + {"name": "name", "kind": "Text"}, + {"name": "updated_at", "kind": "DateTime"}, + ], + "relationships": [], + }, + ] + } + + +@pytest.fixture +def mock_schema_with_proposed_change(httpx_mock: HTTPXMock, schema_with_proposed_change: dict) -> HTTPXMock: + """Mock schema endpoint with CoreProposedChange.""" + httpx_mock.add_response( + method="GET", + url="http://mock/api/schema?branch=main", + json=schema_with_proposed_change, + is_reusable=True, + ) + return httpx_mock + + +@pytest.fixture +def mock_branch_report_with_proposed_changes(httpx_mock: HTTPXMock) -> HTTPXMock: + """Mock all responses for branch report with proposed changes.""" + httpx_mock.add_response( + method="POST", + json={ + "data": { + "Branch": [ + { + "id": "test-branch-id", + "name": "test-branch", + "sync_with_git": True, + "is_default": False, + "origin_branch": "main", + "branched_from": "2025-11-01T10:00:00Z", + "has_schema_changes": False, + "status": "OPEN", + } + ] + } + }, + match_headers={"X-Infrahub-Tracker": "query-branch"}, + ) + + httpx_mock.add_response( + method="POST", + json={ + "data": { + "DiffTree": { + "num_added": 5, + "num_updated": 3, + "num_removed": 1, + "num_conflicts": 0, + "to_time": "2025-11-14T18:00:00Z", + "from_time": "2025-11-01T10:00:00Z", + "base_branch": "main", + "diff_branch": "test-branch", + "name": None, + "nodes": [], + } + } + }, + ) + + httpx_mock.add_response( + method="GET", + json={"test-branch": {"repo-1": {"files": [{"path": "test.py"}]}}}, + ) + + httpx_mock.add_response( + method="POST", + json={ + "data": { + "CoreProposedChange": { + "count": 2, + "edges": [ + { + "node": { + "id": "18789937-1263-f1cb-3615-c51259b5cd8c", + "hfid": None, + "display_label": "Add new feature", + "__typename": "CoreProposedChange", + "is_draft": {"value": False}, + "name": {"value": "Add new feature"}, + "state": {"value": "open"}, + "approved_by": { + "count": 2, + "edges": [ + { + "node": { + "id": "user-1", + "__typename": "CoreAccount", + } + }, + { + "node": { + "id": "user-2", + "__typename": "CoreAccount", + } + }, + ], + }, + "rejected_by": {"count": 0, "edges": []}, + "created_by": { + "node": { + "id": "187895d8-723e-8f5d-3614-c517ac8e761c", + "hfid": ["johndoe"], + "display_label": "John Doe", + "__typename": "CoreAccount", + "name": {"value": "John Doe"}, + "updated_at": "2025-11-10T14:30:00Z", + } + }, + } + }, + { + "node": { + "id": "28789937-1263-f1cb-3615-c51259b5cd8d", + "hfid": None, + "display_label": "Fix bug in network module", + "__typename": "CoreProposedChange", + "is_draft": {"value": True}, + "name": {"value": "Fix bug in network module"}, + "state": {"value": "merged"}, + "approved_by": { + "count": 1, + "edges": [ + { + "node": { + "id": "user-3", + "__typename": "CoreAccount", + } + }, + ], + }, + "rejected_by": { + "count": 2, + "edges": [ + { + "node": { + "id": "user-4", + "__typename": "CoreAccount", + } + }, + { + "node": { + "id": "user-5", + "__typename": "CoreAccount", + } + }, + ], + }, + "created_by": { + "node": { + "id": "287895d8-723e-8f5d-3614-c517ac8e762c", + "hfid": ["janesmith"], + "display_label": "Jane Smith", + "__typename": "CoreAccount", + "name": {"value": "Jane Smith"}, + "updated_at": "2025-11-12T09:15:00Z", + } + }, + } + }, + ], + } + } + }, + ) + + return httpx_mock + + +def test_branch_report_command_with_proposed_changes( + mock_branch_report_with_proposed_changes: HTTPXMock, + mock_schema_with_proposed_change: HTTPXMock, +) -> None: + """Test branch report CLI command with proposed changes.""" + runner = CliRunner() + result = runner.invoke(app, ["report", "test-branch"]) + + assert result.exit_code == 0, f"Command failed: {result.stdout}" + assert "Branch: test-branch" in result.stdout + + assert "Proposed change: Add new feature" in result.stdout + assert "Proposed change: Fix bug in network module" in result.stdout + + assert "open" in result.stdout + assert "merged" in result.stdout + + assert "Is draft No" in result.stdout + assert "Is draft Yes" in result.stdout + + assert "John Doe" in result.stdout + assert "Jane Smith" in result.stdout + + assert "Approvals 2" in result.stdout + assert "Rejections 0" in result.stdout + assert "Approvals 1" in result.stdout + assert "Rejections 2" in result.stdout From 5c95dbfe809421ee9c87d4ad92e45536fbfe8e18 Mon Sep 17 00:00:00 2001 From: wvandeun Date: Mon, 17 Nov 2025 12:34:36 -0600 Subject: [PATCH 07/10] ruff --- tests/unit/sdk/test_diff_summary.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/tests/unit/sdk/test_diff_summary.py b/tests/unit/sdk/test_diff_summary.py index d0c4e461..184bd8ff 100644 --- a/tests/unit/sdk/test_diff_summary.py +++ b/tests/unit/sdk/test_diff_summary.py @@ -293,11 +293,7 @@ async def test_get_diff_tree(clients: BothClients, mock_diff_tree_with_metadata, @pytest.fixture async def mock_diff_tree_none(httpx_mock: HTTPXMock, client: InfrahubClient) -> HTTPXMock: """Mock diff tree response when no diff exists.""" - response = { - "data": { - "DiffTree": None - } - } + response = {"data": {"DiffTree": None}} httpx_mock.add_response( method="POST", @@ -353,9 +349,7 @@ async def mock_diff_tree_with_params(httpx_mock: HTTPXMock, client: InfrahubClie @pytest.mark.parametrize("client_type", client_types) -async def test_get_diff_tree_with_parameters( - clients: BothClients, mock_diff_tree_with_params, client_type -) -> None: +async def test_get_diff_tree_with_parameters(clients: BothClients, mock_diff_tree_with_params, client_type) -> None: """Test get_diff_tree with name and time range parameters.""" from_time = datetime(2025, 11, 14, 12, 0, 0, tzinfo=timezone.utc) to_time = datetime(2025, 11, 14, 18, 0, 0, tzinfo=timezone.utc) From 6dc242170eab4cd258f18feb034e82b871a02b10 Mon Sep 17 00:00:00 2001 From: wvandeun Date: Mon, 17 Nov 2025 12:45:51 -0600 Subject: [PATCH 08/10] update documentation --- docs/docs/infrahubctl/infrahubctl-branch.mdx | 21 ++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/docs/infrahubctl/infrahubctl-branch.mdx b/docs/docs/infrahubctl/infrahubctl-branch.mdx index 1a6c0b3e..a0524ff5 100644 --- a/docs/docs/infrahubctl/infrahubctl-branch.mdx +++ b/docs/docs/infrahubctl/infrahubctl-branch.mdx @@ -23,6 +23,7 @@ $ infrahubctl branch [OPTIONS] COMMAND [ARGS]... * `list`: List all existing branches. * `merge`: Merge a Branch with main. * `rebase`: Rebase a Branch with main. +* `report`: Generate branch cleanup status report. * `validate`: Validate if a branch has some conflict and... ## `infrahubctl branch create` @@ -118,6 +119,26 @@ $ infrahubctl branch rebase [OPTIONS] BRANCH_NAME * `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml] * `--help`: Show this message and exit. +## `infrahubctl branch report` + +Generate branch cleanup status report. + +**Usage**: + +```console +$ infrahubctl branch report [OPTIONS] BRANCH_NAME +``` + +**Arguments**: + +* `BRANCH_NAME`: Branch name to generate report for [required] + +**Options**: + +* `--update-diff`: Update diff before generating report +* `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml] +* `--help`: Show this message and exit. + ## `infrahubctl branch validate` Validate if a branch has some conflict and is passing all the tests (NOT IMPLEMENTED YET). From eb25399d5cd29140cfb2e8a58d9e0594da9ec0a0 Mon Sep 17 00:00:00 2001 From: wvandeun Date: Mon, 17 Nov 2025 12:56:46 -0600 Subject: [PATCH 09/10] fix mypy --- infrahub_sdk/graphql/renderers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infrahub_sdk/graphql/renderers.py b/infrahub_sdk/graphql/renderers.py index 3cd3e557..6c9974ac 100644 --- a/infrahub_sdk/graphql/renderers.py +++ b/infrahub_sdk/graphql/renderers.py @@ -3,7 +3,7 @@ import json from datetime import datetime from enum import Enum -from typing import Any +from typing import Any, Union from pydantic import BaseModel @@ -67,7 +67,7 @@ def convert_to_graphql_as_string(value: Any, convert_enum: bool = False) -> str: return str(value) -GRAPHQL_VARIABLE_TYPES = type[str | int | float | bool | datetime | None] +GRAPHQL_VARIABLE_TYPES = type[Union[str, int, float, bool, datetime, None]] def render_variables_to_string(data: dict[str, GRAPHQL_VARIABLE_TYPES]) -> str: From 65d588afb162c50bacb2da5a82c477eb8ed28ae8 Mon Sep 17 00:00:00 2001 From: wvandeun Date: Mon, 17 Nov 2025 14:04:22 -0600 Subject: [PATCH 10/10] fix: replace | union syntax with Union for Python 3.9 compatibility Replace runtime | operator with Union from typing to support Python 3.9-3.13: - graphql/constants.py: VARIABLE_TYPE_MAPPING tuples - diff.py: GraphQL query variables dict The | operator for types only works at runtime in Python 3.10+. --- infrahub_sdk/diff.py | 7 ++++--- infrahub_sdk/graphql/constants.py | 11 ++++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/infrahub_sdk/diff.py b/infrahub_sdk/diff.py index e8924736..85e00de2 100644 --- a/infrahub_sdk/diff.py +++ b/infrahub_sdk/diff.py @@ -3,6 +3,7 @@ from datetime import datetime from typing import ( Any, + Union, ) from typing_extensions import NotRequired, TypedDict @@ -199,8 +200,8 @@ def get_diff_tree_query() -> Query: }, variables={ "branch_name": str, - "name": str | None, - "from_time": datetime | None, - "to_time": datetime | None, + "name": Union[str, None], + "from_time": Union[datetime, None], + "to_time": Union[datetime, None], }, ) diff --git a/infrahub_sdk/graphql/constants.py b/infrahub_sdk/graphql/constants.py index e2033155..ecf741f5 100644 --- a/infrahub_sdk/graphql/constants.py +++ b/infrahub_sdk/graphql/constants.py @@ -1,14 +1,15 @@ from datetime import datetime +from typing import Union VARIABLE_TYPE_MAPPING = ( (str, "String!"), - (str | None, "String"), + (Union[str, None], "String"), (int, "Int!"), - (int | None, "Int"), + (Union[int, None], "Int"), (float, "Float!"), - (float | None, "Float"), + (Union[float, None], "Float"), (bool, "Boolean!"), - (bool | None, "Boolean"), + (Union[bool, None], "Boolean"), (datetime, "DateTime!"), - (datetime | None, "DateTime"), + (Union[datetime, None], "DateTime"), )