Skip to content
Open
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
28 changes: 18 additions & 10 deletions lib/src/screens/pipeline_detail/controller_pipeline_detail.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ class _PipelineDetailController with ShareMixin, AdsMixin, ApiErrorHelper {

final pipeStages = ValueNotifier<List<_Stage>?>(null);

/// Graph descriptors identifying the current user as an approver (own descriptor + groups).
Set<String> _approverDescriptors = {};

Timer? _timer;

Pipeline get pipeline => buildDetail.value!.data!.pipeline;
Expand Down Expand Up @@ -60,6 +63,11 @@ class _PipelineDetailController with ShareMixin, AdsMixin, ApiErrorHelper {
final approvals = await api.getPipelineApprovals(pipeline: res.data!.pipeline);
res.data!.pipeline.approvals = approvals.data ?? [];

if (approvals.data?.isNotEmpty ?? false) {
final descriptors = await api.getCurrentUserApproverDescriptors();
_approverDescriptors = descriptors.data ?? {};
}

buildDetail.value = res;

final realLogs = res.data!.timeline.where((r) => r.order < 1000);
Expand Down Expand Up @@ -262,21 +270,21 @@ class _PipelineDetailController with ShareMixin, AdsMixin, ApiErrorHelper {
}

bool _canApprove(Approval approval) {
final pendingStep = approval.steps.firstWhereOrNull((s) => s.isPending);
if (pendingStep == null) return false;

final userEmail = api.user!.emailAddress;
if (_isBlockedApprover(approval)) return false;

return !_isBlockedApprover(approval) && (pendingStep.assignedApprover.uniqueName == userEmail);
return approval.steps.any((s) => s.isPending && _isCurrentUser(s.assignedApprover));
}

bool _isBlockedApprover(Approval approval) {
final pendingStep = approval.steps.firstWhereOrNull((s) => s.isPending);
if (pendingStep == null) return false;
bool _isBlockedApprover(Approval approval) => approval.blockedApprovers.any(_isCurrentUser);

final userEmail = api.user!.emailAddress;
/// Whether [approver] represents the current user, either directly or via a group membership.
bool _isCurrentUser(AssignedApprover approver) {
final user = api.user!;
final email = user.emailAddress?.toLowerCase();

return approval.blockedApprovers.map((a) => a.uniqueName).contains(userEmail);
return (email != null && approver.uniqueName.toLowerCase() == email) ||
(approver.id.isNotEmpty && approver.id == user.id) ||
(approver.descriptor.isNotEmpty && _approverDescriptors.contains(approver.descriptor));
}

Future<void> _approveApproval(Approval approval) async {
Expand Down
40 changes: 40 additions & 0 deletions lib/src/services/azure_api_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,10 @@ abstract class AzureApiService {

Future<ApiResponse<bool>> rejectPipelineApproval({required Approval approval, required String projectId});

/// Returns the graph descriptors that identify the current user as an approver:
/// the user's own descriptor plus every group the user belongs to (transitively).
Future<ApiResponse<Set<String>>> getCurrentUserApproverDescriptors();

Future<ApiResponse<PipelineWithTimeline>> getPipeline({required String projectName, required int id});

Future<ApiResponse<String>> getPipelineTaskLogs({
Expand Down Expand Up @@ -418,6 +422,10 @@ class AzureApiServiceImpl with AppLogger implements AzureApiService {
List<GraphUser> get allUsers => _allUsers;
List<GraphUser> _allUsers = [];

/// Cached set of graph descriptors identifying the current user as an approver
/// (own descriptor + all groups they belong to). Computed at most once per session.
Set<String>? _userApproverDescriptors;

@override
bool get isImageUnauthorized => _isImageUnauthorized;
bool _isImageUnauthorized = false;
Expand Down Expand Up @@ -2471,6 +2479,37 @@ class AzureApiServiceImpl with AppLogger implements AzureApiService {
return ApiResponse.ok(user);
}

@override
Future<ApiResponse<Set<String>>> getCurrentUserApproverDescriptors() async {
if (_userApproverDescriptors != null) return ApiResponse.ok(_userApproverDescriptors!);

if (_allUsers.isEmpty) await _getUsers();

final email = user?.emailAddress;
final descriptor = _allUsers.firstWhereOrNull((u) => u.mailAddress == email)?.descriptor;
if (descriptor == null) return ApiResponse.ok(<String>{});

final descriptors = <String>{descriptor};
// Walk the membership graph upwards to collect every (transitive) group the user belongs to.
final toVisit = <String>[descriptor];
while (toVisit.isNotEmpty) {
final current = toVisit.removeLast();
final res = await _get(
'$_usersBasePath/$_organization/_apis/graph/memberships/$current?direction=up&$_apiVersion-preview',
);
if (res.isError) continue;

final value = (jsonDecode(res.body) as Map<String, dynamic>)['value'] as List<dynamic>? ?? [];
for (final membership in value) {
final container = (membership as Map<String, dynamic>)['containerDescriptor'] as String?;
if (container != null && descriptors.add(container)) toVisit.add(container);
}
}

_userApproverDescriptors = descriptors;
return ApiResponse.ok(descriptors);
}

@override
Future<ApiResponse<GraphUser>> getUserFromDisplayName({required String name}) async {
if (_allUsers.isEmpty) await _getUsers();
Expand Down Expand Up @@ -2509,6 +2548,7 @@ class AzureApiServiceImpl with AppLogger implements AzureApiService {
_organization = '';
_chosenProjects = null;
_allUsers.clear();
_userApproverDescriptors = null;
_user = null;
dispose();
}
Expand Down
5 changes: 5 additions & 0 deletions test/api_service_mock.dart
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,11 @@ class AzureApiServiceMock implements AzureApiService {
return ApiResponse.ok(true);
}

@override
Future<ApiResponse<Set<String>>> getCurrentUserApproverDescriptors() async {
return ApiResponse.ok(<String>{});
}

@override
Future<ApiResponse<List<Commit>>> getRecentCommits({
Set<Project>? projects,
Expand Down