Skip to content

Commit ab8e3af

Browse files
authored
feat(protected_environments): replace approval groups (#657)
* feat(protected_environments): replace approval groups Let maintainers replace one approver group while keeping its record identity, approval count, membership and other protection grants. Allow only the selected display description to follow the new group and require matching authoritative confirmation after a single non-replayed request. Signed-off-by: sjungwon03 <sjungwon03@gmail.com> * docs(parity): link approval group replacement pull request Record the concrete review artifact while keeping the approval and CI boundary explicit. Signed-off-by: sjungwon03 <sjungwon03@gmail.com> --------- Signed-off-by: sjungwon03 <sjungwon03@gmail.com>
1 parent c76bfad commit ab8e3af

25 files changed

Lines changed: 1958 additions & 2 deletions

‎apps/labfox/lib/features/protected_environments/data/protected_environments_repository.dart‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,18 @@ class ProtectedEnvironmentsRepository {
154154
int page = 1,
155155
}) => client.groupProtectedEnvironments.list(groupId, page: page);
156156

157+
Future<ProtectedEnvironment> editApprovalGroup(
158+
int projectId,
159+
String name, {
160+
required int ruleId,
161+
required int groupId,
162+
}) => client.protectedEnvironments.editApprovalGroup(
163+
projectId,
164+
name,
165+
ruleId: ruleId,
166+
groupId: groupId,
167+
);
168+
157169
Future<ProtectedEnvironment> getGroup(int groupId, String name) =>
158170
client.groupProtectedEnvironments.get(groupId, name);
159171
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import 'package:flutter_riverpod/flutter_riverpod.dart';
2+
import 'package:gitlab_api/gitlab_api.dart';
3+
import 'package:gitlab_models/gitlab_models.dart';
4+
5+
import 'protected_environments_controller.dart';
6+
7+
class ProtectedEnvironmentApprovalGroupEditController
8+
extends FamilyAsyncNotifier<void, ProtectedEnvironmentRef> {
9+
bool _disposed = false;
10+
11+
@override
12+
void build(ProtectedEnvironmentRef arg) {
13+
ref.watch(protectedEnvironmentsRepositoryProvider.future);
14+
_disposed = false;
15+
ref.onDispose(() => _disposed = true);
16+
}
17+
18+
Future<ProtectedEnvironment> inspect() async {
19+
final session = ref.read(protectedEnvironmentsRepositoryProvider.future);
20+
bool isCurrent() =>
21+
!_disposed &&
22+
identical(
23+
session,
24+
ref.read(protectedEnvironmentsRepositoryProvider.future),
25+
);
26+
final repository = await session;
27+
if (!isCurrent()) {
28+
throw const GitLabConflictException('Protection session changed');
29+
}
30+
if (repository == null) throw StateError('No authenticated account');
31+
await repository.ensureUniqueName(arg.projectId, arg.name);
32+
if (!isCurrent()) {
33+
throw const GitLabConflictException('Protection session changed');
34+
}
35+
final detail = await repository.getForApprovalGroupEdit(
36+
arg.projectId,
37+
arg.name,
38+
);
39+
if (!isCurrent()) {
40+
throw const GitLabConflictException('Protection session changed');
41+
}
42+
return detail;
43+
}
44+
45+
Future<void> edit({
46+
required ProtectedEnvironment expected,
47+
required int ruleId,
48+
required int groupId,
49+
}) async {
50+
if (state.isLoading) {
51+
throw StateError('Approval group replacement already pending');
52+
}
53+
if (expected.name != arg.name ||
54+
arg.name.trim().isEmpty ||
55+
arg.name != arg.name.trim() ||
56+
ruleId <= 0 ||
57+
groupId <= 0) {
58+
throw ArgumentError(
59+
'Exact environment rule and positive record and group IDs required',
60+
);
61+
}
62+
final session = ref.read(protectedEnvironmentsRepositoryProvider.future);
63+
bool isCurrent() =>
64+
!_disposed &&
65+
identical(
66+
session,
67+
ref.read(protectedEnvironmentsRepositoryProvider.future),
68+
);
69+
void invalidate() {
70+
ref.invalidate(protectedEnvironmentsControllerProvider(arg.projectId));
71+
ref.invalidate(protectedEnvironmentDetailProvider(arg));
72+
}
73+
74+
void confirm(ProtectedEnvironment actual) {
75+
final matches = actual.approvalRules
76+
.where((rule) => rule.id == ruleId)
77+
.toList();
78+
if (matches.length != 1) {
79+
throw const GitLabServerException(
80+
'Unconfirmed approval group replacement',
81+
);
82+
}
83+
final intended = expected.copyWith(
84+
approvalRules: [
85+
for (final rule in expected.approvalRules)
86+
rule.id == ruleId
87+
? rule.copyWith(
88+
groupId: groupId,
89+
description: matches.single.description,
90+
)
91+
: rule,
92+
],
93+
);
94+
if (actual != intended) {
95+
throw const GitLabServerException(
96+
'Unconfirmed approval group replacement',
97+
);
98+
}
99+
}
100+
101+
var attempted = false;
102+
state = const AsyncLoading();
103+
try {
104+
final current = await inspect();
105+
if (!isCurrent() || current != expected) {
106+
throw const GitLabConflictException('Protection rule changed');
107+
}
108+
final matches = current.approvalRules
109+
.where((rule) => rule.id == ruleId)
110+
.toList();
111+
if (matches.length != 1 ||
112+
!editableApprovalGroupTarget(matches.single) ||
113+
current.approvalRules.any((rule) => rule.groupId == groupId)) {
114+
throw const GitLabConflictException(
115+
'Approval group is unavailable, unchanged or already represented',
116+
);
117+
}
118+
final repository = await session;
119+
if (!isCurrent()) {
120+
throw const GitLabConflictException('Protection session changed');
121+
}
122+
if (repository == null) throw StateError('No authenticated account');
123+
attempted = true;
124+
final updated = await repository.editApprovalGroup(
125+
arg.projectId,
126+
arg.name,
127+
ruleId: ruleId,
128+
groupId: groupId,
129+
);
130+
if (!isCurrent()) {
131+
throw const GitLabConflictException('Protection session changed');
132+
}
133+
confirm(updated);
134+
final confirmed = await repository.getForApprovalGroupEdit(
135+
arg.projectId,
136+
arg.name,
137+
);
138+
if (!isCurrent()) {
139+
throw const GitLabConflictException('Protection session changed');
140+
}
141+
confirm(confirmed);
142+
if (confirmed != updated) {
143+
throw const GitLabServerException(
144+
'Approval group changed during confirmation',
145+
);
146+
}
147+
state = const AsyncData(null);
148+
invalidate();
149+
} catch (error, stackTrace) {
150+
if (isCurrent()) {
151+
state = AsyncError(error, stackTrace);
152+
if (attempted) invalidate();
153+
}
154+
rethrow;
155+
}
156+
}
157+
}
158+
159+
bool editableApprovalGroupTarget(ProtectedEnvironmentAccess rule) =>
160+
rule.id != null &&
161+
rule.id! > 0 &&
162+
rule.groupId != null &&
163+
rule.groupId! > 0 &&
164+
rule.userId == null &&
165+
rule.accessLevel == null &&
166+
rule.requiredApprovals != null &&
167+
rule.requiredApprovals! > 0 &&
168+
{0, 1}.contains(rule.groupInheritanceType);
169+
170+
final protectedEnvironmentApprovalGroupEditControllerProvider =
171+
AsyncNotifierProvider.family<
172+
ProtectedEnvironmentApprovalGroupEditController,
173+
void,
174+
ProtectedEnvironmentRef
175+
>(ProtectedEnvironmentApprovalGroupEditController.new);

‎apps/labfox/lib/features/protected_environments/presentation/protected_environment_detail_screen.dart‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ import 'package:go_router/go_router.dart';
77

88
import '../../../app/router.dart';
99
import '../../../l10n/app_localizations.dart';
10+
import 'controllers/protected_environment_approval_group_edit_controller.dart';
1011
import 'controllers/protected_environment_approval_inheritance_controller.dart';
1112
import 'controllers/protected_environments_controller.dart';
1213
import 'widgets/protected_environment_approval_count_dialog.dart';
1314
import 'widgets/protected_environment_approval_group_dialog.dart';
15+
import 'widgets/protected_environment_approval_group_edit_dialog.dart';
1416
import 'widgets/protected_environment_approval_inheritance_dialog.dart';
1517
import 'widgets/protected_environment_deploy_role_dialog.dart';
1618
import 'widgets/protected_environment_edit_role_dialog.dart';
@@ -336,6 +338,51 @@ class ProtectedEnvironmentDetailScreen extends ConsumerWidget {
336338
entries: data.approvalRules,
337339
showRequiredApprovals: true,
338340
),
341+
if (projectKey != null &&
342+
data.approvalRules.any(editableApprovalGroupTarget))
343+
Padding(
344+
padding: const EdgeInsets.only(top: LabFoxSpacing.sm),
345+
child: OutlinedButton.icon(
346+
key: const ValueKey(
347+
'protected-environment-approval-group-edit-open',
348+
),
349+
icon: const Icon(Icons.swap_horiz_outlined),
350+
label: Text(
351+
l10n.protectedEnvironmentApprovalGroupEditTitle,
352+
),
353+
onPressed: () async {
354+
final session = ref.read(
355+
protectedEnvironmentsRepositoryProvider.future,
356+
);
357+
final edited = await showDialog<bool>(
358+
context: context,
359+
barrierDismissible: false,
360+
builder: (_) =>
361+
ProtectedEnvironmentApprovalGroupEditDialog(
362+
target: projectKey,
363+
rule: data,
364+
),
365+
);
366+
if (edited == true &&
367+
context.mounted &&
368+
identical(
369+
session,
370+
ref.read(
371+
protectedEnvironmentsRepositoryProvider
372+
.future,
373+
),
374+
)) {
375+
ScaffoldMessenger.of(context).showSnackBar(
376+
SnackBar(
377+
content: Text(
378+
l10n.protectedEnvironmentApprovalGroupEditSuccess,
379+
),
380+
),
381+
);
382+
}
383+
},
384+
),
385+
),
339386
if (projectKey != null &&
340387
data.approvalRules.any(
341388
(rule) =>

0 commit comments

Comments
 (0)