Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion backend/infrahub/core/merge/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,6 @@ async def build_branch_merge_orchestrator(
workflow=workflow,
event_service=event_service,
default_branch=destination_branch,
global_branch=registry.get_global_branch(),
logger=logger,
)

Expand Down
4 changes: 1 addition & 3 deletions backend/infrahub/core/merge/post_merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,12 @@ def __init__(
workflow: InfrahubWorkflow,
event_service: InfrahubEventService,
default_branch: Branch,
global_branch: Branch,
logger: InfrahubLogger | None = None,
) -> None:
self.repository_merge_dispatcher = repository_merge_dispatcher
self.workflow = workflow
self.event_service = event_service
self.default_branch = default_branch
self.global_branch = global_branch
self.log = logger or get_logger()

async def run_follow_ups(
Expand Down Expand Up @@ -130,7 +128,7 @@ async def dispatch_events(
branch_name=branch.name,
branch_id=str(branch.get_uuid()),
proposed_change_id=proposed_change_id,
meta=EventMeta.from_context(context=event_context, branch=self.global_branch),
meta=EventMeta.from_context(context=event_context, branch=self.default_branch),
)

events: list[InfrahubEvent] = [merge_event]
Expand Down
4 changes: 3 additions & 1 deletion backend/infrahub/graphql/mutations/proposed_change.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,9 @@ async def _handle_decision(
approved_by_ids = [node.id for _, node in approved_by.items()]
rejected_by_ids = [node.id for _, node in rejected_by.items()]
event: InfrahubEvent | None = None
event_meta = EventMeta.from_context(context=context.get_context().to_event_context())
# Proposed changes are branch-agnostic; scope the review events to the default branch regardless of the branch the review mutation ran on.
default_branch = await registry.get_branch(db=db, branch=registry.default_branch)
event_meta = EventMeta.from_context(context=context.get_context().to_event_context(), branch=default_branch)

match decision:
case ProposedChangeApprovalDecision.APPROVE:
Expand Down
99 changes: 71 additions & 28 deletions backend/tests/component/core/merge/test_post_merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,30 @@
from infrahub.database import InfrahubDatabase


def _build_dispatcher(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extracted as this is now reused in other test classes

db: InfrahubDatabase,
source_branch: Branch,
destination_branch: Branch,
event_service: MemoryInfrahubEvent,
) -> PostMergeDispatcher:
workflow = WorkflowLocalExecution()
return PostMergeDispatcher(
repository_merge_dispatcher=RepositoryMergeDispatcher(
db=db, source_branch=source_branch, destination_branch=destination_branch, workflow=workflow
),
workflow=workflow,
event_service=event_service,
default_branch=destination_branch,
)


def _context(default_branch: Branch) -> InfrahubContext:
return InfrahubContext.init(
branch=default_branch,
account=AccountSession(account_id=str(uuid4()), auth_type=AuthType.NONE),
)


class TestPostMergeSchemaEvent:
"""A merge that applied schema changes emits a scoped SchemaUpdatedEvent for the destination branch.

Expand All @@ -29,30 +53,6 @@ class TestPostMergeSchemaEvent:
elements the merge actually changed.
"""

def _build_dispatcher(
self,
db: InfrahubDatabase,
source_branch: Branch,
destination_branch: Branch,
event_service: MemoryInfrahubEvent,
) -> PostMergeDispatcher:
workflow = WorkflowLocalExecution()
return PostMergeDispatcher(
repository_merge_dispatcher=RepositoryMergeDispatcher(
db=db, source_branch=source_branch, destination_branch=destination_branch, workflow=workflow
),
workflow=workflow,
event_service=event_service,
default_branch=destination_branch,
global_branch=registry.get_global_branch(),
)

def _context(self, default_branch: Branch) -> InfrahubContext:
return InfrahubContext.init(
branch=default_branch,
account=AccountSession(account_id=str(uuid4()), auth_type=AuthType.NONE),
)

async def test_emits_scoped_schema_updated_event_when_schema_changed(
self,
db: InfrahubDatabase,
Expand All @@ -62,7 +62,7 @@ async def test_emits_scoped_schema_updated_event_when_schema_changed(
) -> None:
source_branch = await create_branch(branch_name="feature", db=db)
memory_event = MemoryInfrahubEvent()
dispatcher = self._build_dispatcher(db, source_branch, default_branch, memory_event)
dispatcher = _build_dispatcher(db, source_branch, default_branch, memory_event)

# A schema change confined to a derived-value definition on the destination branch.
base_schema = registry.schema.get_schema_branch(name=default_branch.name)
Expand All @@ -77,7 +77,7 @@ async def test_emits_scoped_schema_updated_event_when_schema_changed(
branch=source_branch,
proposed_change_id=None,
node_events=[],
context=self._context(default_branch),
context=_context(default_branch),
schema_diff=schema_diff,
schema_hash=candidate.get_hash(),
)
Expand All @@ -99,15 +99,58 @@ async def test_no_schema_updated_event_when_no_schema_change(
) -> None:
source_branch = await create_branch(branch_name="feature", db=db)
memory_event = MemoryInfrahubEvent()
dispatcher = self._build_dispatcher(db, source_branch, default_branch, memory_event)
dispatcher = _build_dispatcher(db, source_branch, default_branch, memory_event)

await dispatcher.dispatch_events(
branch=source_branch,
proposed_change_id=None,
node_events=[],
context=self._context(default_branch),
context=_context(default_branch),
schema_diff=None,
)

assert not [event for event in memory_event.events if isinstance(event, SchemaUpdatedEvent)]
assert [event for event in memory_event.events if isinstance(event, BranchMergedEvent)]


class TestPostMergeBranchMergedEvent:
"""The branch-merged event is scoped to the default branch for webhook matching.

Webhook branch scoping matches the event's `infrahub.branch` related-resource label, so a
Default-Branch scoped webhook fires for a merge only when that label is the default branch. The
event payload still identifies the branch that was merged.
"""

async def test_branch_merged_event_scoped_to_default_branch(
self,
db: InfrahubDatabase,
default_branch: Branch,
register_core_models_schema: SchemaBranch,
car_person_schema: SchemaBranch,
) -> None:
source_branch = await create_branch(branch_name="feature", db=db)
memory_event = MemoryInfrahubEvent()
dispatcher = _build_dispatcher(db, source_branch, default_branch, memory_event)

await dispatcher.dispatch_events(
branch=source_branch,
proposed_change_id=None,
node_events=[],
context=_context(default_branch),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be context=_context(source_branch) to validate that the fix works properly?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes you're right, thanks @gmazoyer

schema_diff=None,
)

merged_events = [event for event in memory_event.events if isinstance(event, BranchMergedEvent)]
assert len(merged_events) == 1
event = merged_events[0]

# Payload identity keeps naming the branch that was merged.
assert event.branch_name == source_branch.name
assert event.branch_id == str(source_branch.get_uuid())

# The webhook scoping branch is the default branch, not the global branch.
branch_related = [
entry for entry in event.get_related() if entry.get("prefect.resource.role") == "infrahub.branch"
]
assert len(branch_related) == 1
assert branch_related[0]["infrahub.resource.label"] == default_branch.name
50 changes: 50 additions & 0 deletions backend/tests/functional/proposed_change/test_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import pytest
from infrahub_sdk.exceptions import GraphQLError
from prefect import get_client
from tests.helpers.events import query_events_by_name
from tests.helpers.test_app import TestInfrahubApp

from infrahub.core.constants.infrahubkind import PROPOSEDCHANGE
Expand Down Expand Up @@ -117,6 +118,55 @@ async def test_approve_then_reject(
resource_id=f"infrahub.proposed_change.{proposed_change.id}",
)

async def test_review_event_scoped_to_default_branch(
self,
client: InfrahubClient,
db: InfrahubDatabase,
car_person_schema: SchemaBranch,
unprivileged_client: InfrahubClient,
prefect_client: PrefectClient,
) -> None:
"""A review submitted through a non-default branch still scopes its event to the default branch.

Webhook branch scoping matches the event's `infrahub.branch` related-resource label, so the
review event must carry the default branch there regardless of the branch the mutation ran on.
"""
source_branch = await create_branch(branch_name="branch-pc-review-scope", db=db)

proposed_change = await client.create(
kind=PROPOSEDCHANGE,
data={
"source_branch": source_branch.name,
"destination_branch": "main",
"name": "test-pc-review-scope",
},
)
await proposed_change.save()

response = await unprivileged_client.execute_graphql(
query=self.review_query,
variables={"data": {"id": str(proposed_change.id), "decision": "APPROVE"}},
branch_name=source_branch.name,
)
assert response["CoreProposedChangeReview"]["ok"] is True

resource_id = f"infrahub.proposed_change.{proposed_change.id}"
await self.assert_event(
prefect_client=prefect_client,
event_name="infrahub.proposed_change.approved",
resource_id=resource_id,
)

events = await query_events_by_name(
client=prefect_client,
event_name="infrahub.proposed_change.approved",
resource_id=resource_id,
)
assert len(events) == 1
branch_related = [related for related in events[0].related if related.role == "infrahub.branch"]
assert len(branch_related) == 1
assert branch_related[0]["infrahub.resource.label"] == "main"

async def test_cancel_approve(
self,
client: InfrahubClient,
Expand Down
1 change: 1 addition & 0 deletions changelog/9761.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Branch merge and proposed change review events are now emitted on the default branch, so webhooks scoped to the default branch reliably match them regardless of which branch triggered the change.
9 changes: 9 additions & 0 deletions dev/knowledge/backend/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,15 @@ The `EventMeta` class provides rich context:

Use `EventMeta.from_parent()` to create child events that maintain hierarchy.

## Scoping branch for webhook matching

Webhook branch scoping matches an event against `meta.context.branch` (see [Webhooks](webhooks.md)). Not every branch-agnostic event overrides the caller's context, so the scoping branch is set per event:

- Proposed change merge and review events (merged, approved, rejected, and the approval/rejection revoke variants) are stamped to the default branch, so scoping is independent of the branch the mutation ran on.
- `branch.merged` is stamped to the default branch as well, since the merge lands there. Its payload still carries the merged branch in `branch_name` / `branch_id`; only the scoping branch is the default one.
- `branch.created` and `branch.deleted` are stamped to the global branch, pending a general rule for branch-agnostic node events.
- `branch.rebased` and `branch.migrated` inherit the caller's context branch; they are not overridden.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder whether it would be relevant to assign the target branch for those operations


## Querying Events

Events can be queried through:
Expand Down
6 changes: 5 additions & 1 deletion docs/docs/webhooks/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,15 @@ Webhooks can also be configured to trigger based on branch types:

This flexibility ensures that webhooks only trigger when relevant, depending on the use case. For example, you might configure a webhook to notify an external system only when a user makes changes directly to the default branch.

:::warning Branch scope for proposed change events
:::warning Branch scope for proposed change and branch events

The branch scope is matched against the branch an event is emitted on. A [proposed change](../reference/infrahub-events/proposed.mdx) is a branch-agnostic object, so the events for its lifecycle are emitted on the **default branch**, regardless of the proposed change's source branch:

- `infrahub.proposed_change.merged`
- `infrahub.proposed_change.approved`
- `infrahub.proposed_change.rejected`
- `infrahub.proposed_change.approval_revoked`
- `infrahub.proposed_change.rejection_revoked`

Webhooks for these events must use the **Default Branch** or **All Branches** scope. Scoping them to **Other Branches** means the webhook will never fire.

Expand All @@ -68,6 +70,8 @@ By contrast, events generated by activity *inside* a proposed change are emitted
- `infrahub.proposed_change_thread.created` (comments and threads)
- validator and data-change events produced by the proposed change pipeline

The same distinction applies to branch lifecycle events. `infrahub.branch.merged` is emitted on the **default branch**, so it requires the **Default Branch** or **All Branches** scope. `infrahub.branch.created` and `infrahub.branch.deleted` are emitted on the global branch, so their payload `branch` is `null` and they are caught by the **Other Branches** or **All Branches** scope, never by **Default Branch**.

:::

### Node kind
Expand Down
Loading