Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions backend/infrahub/core/branch/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ async def merge_branch(branch: str, context: InfrahubContext, proposed_change_id


@flow(name="branch-delete", flow_run_name="Delete branch {branch}")
async def delete_branch(branch: str, context: InfrahubContext) -> None:
async def delete_branch(branch: str, context: InfrahubContext, delete_from_git: bool = False) -> None:
await add_tags(branches=[branch])

database = await get_database()
Expand All @@ -439,7 +439,7 @@ async def delete_branch(branch: str, context: InfrahubContext) -> None:
event_service = await get_event_service()
await event_service.send(event=event)

should_delete_git = config.SETTINGS.git.delete_git_branch_after_merge and obj.sync_with_git
should_delete_git = (config.SETTINGS.git.delete_git_branch_after_merge or delete_from_git) and obj.sync_with_git
if should_delete_git:
await get_workflow().submit_workflow(
workflow=GIT_REPOSITORIES_DELETE_BRANCH,
Expand Down
18 changes: 14 additions & 4 deletions backend/infrahub/graphql/mutations/branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ class BranchNameInput(InputObjectType):
name = String(required=False)


class BranchDeleteInput(InputObjectType):
name = String(required=False)
delete_from_git = Boolean(required=False, default_value=False)


class BranchUpdateInput(InputObjectType):
name = String(required=True)
description = String(required=False)
Expand All @@ -117,7 +122,7 @@ class BranchUpdateInput(InputObjectType):

class BranchDelete(Mutation):
class Arguments:
data = BranchNameInput(required=True)
data = BranchDeleteInput(required=True)
context = ContextInput(required=False)
wait_until_completion = Boolean(required=False)

Expand All @@ -129,22 +134,27 @@ async def mutate(
cls,
root: dict, # noqa: ARG003
info: GraphQLResolveInfo,
data: BranchNameInput,
data: BranchDeleteInput,
context: ContextInput | None = None,
wait_until_completion: bool = True,
) -> Self:
graphql_context: GraphqlContext = info.context
obj = await Branch.get_by_name(db=graphql_context.db, name=str(data.name))
await apply_external_context(graphql_context=graphql_context, context_input=context)

parameters = {
"branch": obj.name,
"delete_from_git": bool(data.delete_from_git),
}

if wait_until_completion:
await graphql_context.active_service.workflow.execute_workflow(
workflow=BRANCH_DELETE, context=graphql_context.get_context(), parameters={"branch": obj.name}
workflow=BRANCH_DELETE, context=graphql_context.get_context(), parameters=parameters
)
return cls(ok=True)

workflow = await graphql_context.active_service.workflow.submit_workflow(
workflow=BRANCH_DELETE, context=graphql_context.get_context(), parameters={"branch": obj.name}
workflow=BRANCH_DELETE, context=graphql_context.get_context(), parameters=parameters
)
return cls(ok=True, task={"id": str(workflow.id)})

Expand Down
39 changes: 39 additions & 0 deletions backend/tests/functional/branch/test_delete_git_branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,42 @@ async def test_git_deletion_not_triggered_when_branch_not_sync_with_git(
c for c in mock_submit.call_args_list if c.kwargs.get("workflow") == GIT_REPOSITORIES_DELETE_BRANCH
]
assert not git_del_calls

async def test_git_deletion_triggered_when_delete_from_git_true_and_config_disabled(
self, initial_dataset: None, client: InfrahubClient, delete_git_branch_after_merge_reset_config: None
) -> None:
config.SETTINGS.git.delete_git_branch_after_merge = False
branch = await client.branch.create(branch_name="git_del_explicit_true", sync_with_git=True)

with patch.object(WorkflowLocalExecution, "submit_workflow", new_callable=AsyncMock) as mock_submit:
query = Mutation(
mutation="BranchDelete",
input_data={"data": {"name": branch.name, "delete_from_git": True}},
query={"ok": None},
)
await client.execute_graphql(query=query.render())

mock_submit.assert_any_call(
workflow=GIT_REPOSITORIES_DELETE_BRANCH,
context=ANY,
parameters={"branch": branch.name},
)

async def test_git_deletion_not_triggered_when_delete_from_git_false_and_config_disabled(
self, initial_dataset: None, client: InfrahubClient, delete_git_branch_after_merge_reset_config: None
) -> None:
config.SETTINGS.git.delete_git_branch_after_merge = False
branch = await client.branch.create(branch_name="git_del_explicit_false", sync_with_git=True)

with patch.object(WorkflowLocalExecution, "submit_workflow", new_callable=AsyncMock) as mock_submit:
query = Mutation(
mutation="BranchDelete",
input_data={"data": {"name": branch.name, "delete_from_git": False}},
query={"ok": None},
)
await client.execute_graphql(query=query.render())

git_del_calls = [
c for c in mock_submit.call_args_list if c.kwargs.get("workflow") == GIT_REPOSITORIES_DELETE_BRANCH
]
assert not git_del_calls
4 changes: 2 additions & 2 deletions dev/wip/ifc-2336/implementation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Add optional automatic branch deletion after merge. Both Infrahub branch deletio

**Spec**: [dev/specs/infp-389-branch-merge-delete/spec.md](../../specs/infp-389-branch-merge-delete/spec.md)
**Jira**: INFP-389
**Scope**: Backend only
**Scope**: Backend + docs + frontend types (generated)

---

Expand All @@ -17,7 +17,7 @@ Add optional automatic branch deletion after merge. Both Infrahub branch deletio
| 1 | Configuration settings | P1 | ✅ Done | 3 unit tests |
| 2 | Auto-delete Infrahub branch after merge | P1 | ✅ Done | 4 functional tests |
| 3 | Git branch deletion workflow | P2 | ✅ Done | 4 component + 3 functional + 2 integration tests |
| 4 | Manual delete with Git option | P3 | ⬜ Todo | 2 unit tests |
| 4 | Manual delete with Git option | P3 | ✅ Done | 2 functional tests |

**Total Tests:** 14 tests (3 unit + 4 functional + 4 component + 2 integration + 1 unit)

Expand Down
20 changes: 10 additions & 10 deletions dev/wip/ifc-2336/phase-4.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Phase 4: Manual Delete with Git Option

**Status:** ⬜ Todo
**Status:** ✅ Done
**Priority:** P3
**Requirements:** FR-008, FR-009
**Depends on:** Phase 3
Expand All @@ -15,11 +15,11 @@ Expose `delete_from_git` as an optional parameter on the `BranchDelete` GraphQL

## Checklist

- [ ] Add `BranchDeleteInput` class (new — `BranchNameInput` is unchanged)
- [ ] Switch `BranchDelete.Arguments` and `mutate()` type hint from `BranchNameInput` to `BranchDeleteInput`
- [ ] Pass `delete_from_git` through to the `BRANCH_DELETE` workflow parameters
- [ ] Update `delete_branch()` to accept `delete_from_git: bool = False` and wire it into `should_delete_git`
- [ ] Write unit tests
- [x] Add `BranchDeleteInput` class (new — `BranchNameInput` is unchanged)
- [x] Switch `BranchDelete.Arguments` and `mutate()` type hint from `BranchNameInput` to `BranchDeleteInput`
- [x] Pass `delete_from_git` through to the `BRANCH_DELETE` workflow parameters
- [x] Update `delete_branch()` to accept `delete_from_git: bool = False` and wire it into `should_delete_git`
- [x] Write tests

---

Expand Down Expand Up @@ -117,13 +117,13 @@ return cls(ok=True, task={"id": str(workflow.id)})

## Tests

**File:** `backend/tests/unit/graphql/mutations/test_branch_delete.py`
**File:** `backend/tests/functional/branch/test_delete_git_branch.py`

- `test_branch_delete_with_git_option_triggers_git_deletion` — call mutation with `delete_from_git: true`, assert `GIT_REPOSITORIES_DELETE_BRANCH` workflow is submitted
- `test_branch_delete_without_git_option_skips_git_deletion` — call mutation with `delete_from_git: false` (and config disabled), assert git workflow not submitted
- `test_git_deletion_triggered_when_delete_from_git_true_and_config_disabled` — mutation with `delete_from_git: true`, config disabled, assert `GIT_REPOSITORIES_DELETE_BRANCH` is submitted
- `test_git_deletion_not_triggered_when_delete_from_git_false_and_config_disabled` — mutation with `delete_from_git: false`, config disabled, assert git workflow not submitted

**Verification:**

```bash
uv run pytest backend/tests/unit/graphql/mutations/test_branch_delete.py -v
uv run pytest backend/tests/functional/branch/test_delete_git_branch.py -v
```
1 change: 1 addition & 0 deletions frontend/app/src/shared/api/graphql/graphql-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type introspection_types = {
'BranchCreateInput': { kind: 'INPUT_OBJECT'; name: 'BranchCreateInput'; isOneOf: false; inputFields: [{ name: 'branched_from'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'description'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'id'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'is_isolated'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; defaultValue: null }, { name: 'name'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; defaultValue: null }, { name: 'origin_branch'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'sync_with_git'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; defaultValue: null }]; };
'BranchCreatedEvent': { kind: 'OBJECT'; name: 'BranchCreatedEvent'; fields: { 'account_id': { name: 'account_id'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'branch': { name: 'branch'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'created_branch': { name: 'created_branch'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'event': { name: 'event'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'has_children': { name: 'has_children'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'level': { name: 'level'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'occurred_at': { name: 'occurred_at'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; }; } }; 'parent_id': { name: 'parent_id'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'payload': { name: 'payload'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'GenericScalar'; ofType: null; }; } }; 'primary_node': { name: 'primary_node'; type: { kind: 'OBJECT'; name: 'RelatedNode'; ofType: null; } }; 'related_nodes': { name: 'related_nodes'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'RelatedNode'; ofType: null; }; }; }; } }; }; };
'BranchDelete': { kind: 'OBJECT'; name: 'BranchDelete'; fields: { 'ok': { name: 'ok'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; 'task': { name: 'task'; type: { kind: 'OBJECT'; name: 'TaskInfo'; ofType: null; } }; }; };
'BranchDeleteInput': { kind: 'INPUT_OBJECT'; name: 'BranchDeleteInput'; isOneOf: false; inputFields: [{ name: 'delete_from_git'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; defaultValue: "false" }, { name: 'name'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }]; };
'BranchDeletedEvent': { kind: 'OBJECT'; name: 'BranchDeletedEvent'; fields: { 'account_id': { name: 'account_id'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'branch': { name: 'branch'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'deleted_branch': { name: 'deleted_branch'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'event': { name: 'event'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'has_children': { name: 'has_children'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'level': { name: 'level'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'occurred_at': { name: 'occurred_at'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; }; } }; 'parent_id': { name: 'parent_id'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'payload': { name: 'payload'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'GenericScalar'; ofType: null; }; } }; 'primary_node': { name: 'primary_node'; type: { kind: 'OBJECT'; name: 'RelatedNode'; ofType: null; } }; 'related_nodes': { name: 'related_nodes'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'RelatedNode'; ofType: null; }; }; }; } }; }; };
'BranchEventTypeFilter': { kind: 'INPUT_OBJECT'; name: 'BranchEventTypeFilter'; isOneOf: false; inputFields: [{ name: 'branches'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; }; }; defaultValue: null }]; };
'BranchMerge': { kind: 'OBJECT'; name: 'BranchMerge'; fields: { 'object': { name: 'object'; type: { kind: 'OBJECT'; name: 'Branch'; ofType: null; } }; 'ok': { name: 'ok'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; 'task': { name: 'task'; type: { kind: 'OBJECT'; name: 'TaskInfo'; ofType: null; } }; }; };
Expand Down
7 changes: 6 additions & 1 deletion schema/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,11 @@ type BranchDelete {
task: TaskInfo
}

input BranchDeleteInput {
delete_from_git: Boolean = false
name: String
}

type BranchDeletedEvent implements EventNodeInterface {
"""The account ID that triggered the event."""
account_id: String
Expand Down Expand Up @@ -8831,7 +8836,7 @@ type MacAddress implements AttributeInterface {

type Mutation {
BranchCreate(background_execution: Boolean @deprecated(reason: "Please use `wait_until_completion` instead"), context: ContextInput, data: BranchCreateInput!, wait_until_completion: Boolean): BranchCreate
BranchDelete(context: ContextInput, data: BranchNameInput!, wait_until_completion: Boolean): BranchDelete
BranchDelete(context: ContextInput, data: BranchDeleteInput!, wait_until_completion: Boolean): BranchDelete
BranchMerge(context: ContextInput, data: BranchNameInput!, wait_until_completion: Boolean): BranchMerge
BranchRebase(context: ContextInput, data: BranchNameInput!, wait_until_completion: Boolean): BranchRebase
BranchUpdate(context: ContextInput, data: BranchUpdateInput!): BranchUpdate
Expand Down
Loading