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
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ class CommentsRepository {
String? commitMessage,
}) => _client.suggestions.apply(suggestionId, commitMessage: commitMessage);

Future<List<Suggestion>> applySuggestions({
required List<int> suggestionIds,
String? commitMessage,
}) => _client.suggestions.applyBatch(
suggestionIds,
commitMessage: commitMessage,
);

Future<Note> replyToDiscussion({
required int projectId,
required int iid,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import 'package:gitlab_models/gitlab_models.dart';

typedef SuggestionTarget = ({
String discussionId,
Note note,
Suggestion suggestion,
});

/// Patch eligibility is not a permission check: GitLab enforces write access.
bool canApplySuggestion(Note note, Suggestion suggestion) =>
!note.isSystem &&
Expand Down Expand Up @@ -53,3 +59,37 @@ bool confirmsAppliedSuggestion(Suggestion returned, Suggestion expected) =>
(returned.fromContent == null ||
returned.fromContent == expected.fromContent) &&
(returned.toContent == null || returned.toContent == expected.toContent);

List<SuggestionTarget> applicableSuggestionTargets(List<Discussion> groups) {
final counts = <int, int>{};
for (final group in groups) {
for (final note in group.notes) {
for (final suggestion in note.suggestions ?? <Suggestion>[]) {
counts.update(suggestion.id, (v) => v + 1, ifAbsent: () => 1);
}
}
}
return List<SuggestionTarget>.unmodifiable([
for (final group in groups)
for (final note in group.notes)
for (final suggestion in note.suggestions ?? <Suggestion>[])
if (counts[suggestion.id] == 1 &&
canApplySuggestion(note, suggestion))
(discussionId: group.id, note: note, suggestion: suggestion),
]);
}

bool containsApplicableSuggestionTargets(
List<Discussion> groups,
List<SuggestionTarget> targets,
) =>
targets.length >= 2 &&
targets.map((t) => t.suggestion.id).toSet().length == targets.length &&
targets.every(
(t) => containsApplicableSuggestion(
groups,
discussionId: t.discussionId,
note: t.note,
suggestion: t.suggestion,
),
);
Original file line number Diff line number Diff line change
Expand Up @@ -214,12 +214,96 @@ class MrDiscussionsController
}
}

bool _currentSuggestions(List<SuggestionTarget> targets) =>
!state.isLoading &&
!state.hasError &&
state.hasValue &&
containsApplicableSuggestionTargets(state.value!.items, targets);

/// Confirms all selected patches before one batch; never applies a subset.
Future<bool> applySuggestions(
List<SuggestionTarget> selection, {
String? commitMessage,
bool Function()? isCurrent,
}) async {
final targets = List<SuggestionTarget>.unmodifiable(selection);
if (_posting ||
_loadingMore ||
!_currentSuggestions(targets) ||
isCurrent?.call() == false) {
return false;
}
final generation = _generation;
_posting = true;
bool current() => generation == _generation && isCurrent?.call() != false;
try {
final repo = await _repository();
if (!current()) return false;
if (repo == null) {
throw const GitLabAuthException('No authenticated account.');
}
if (!_currentSuggestions(targets)) return false;
final fresh = await _readDiscussions(
repo,
targets.map((t) => t.discussionId).toSet().toList(),
current,
);
if (!current() || fresh == null) return false;
if (!_currentSuggestions(targets) ||
!containsApplicableSuggestionTargets(fresh, targets)) {
throw const GitLabConflictException(
'Suggestions changed. Reload before applying.',
);
}
final returned = await repo.applySuggestions(
suggestionIds: targets.map((t) => t.suggestion.id).toList(),
commitMessage: commitMessage,
);
if (!current()) return false;
final byId = {for (final s in returned) s.id: s};
if (returned.length != targets.length ||
byId.length != targets.length ||
!targets.every(
(t) =>
byId[t.suggestion.id] != null &&
confirmsAppliedSuggestion(byId[t.suggestion.id]!, t.suggestion),
)) {
throw const GitLabServerException(
'Unconfirmed suggestion batch application.',
);
}
ref.invalidate(mergeRequestControllerProvider(arg));
ref.invalidate(mrReviewSnapshotControllerProvider(arg));
ref.invalidateSelf();
return true;
} catch (_) {
if (!current()) return false;
rethrow;
} finally {
if (generation == _generation) _posting = false;
}
}

/// Explicit read-only recovery never retries an application automatically.
Future<Discussion?> inspectDiscussion(
String discussionId, {
bool Function()? isCurrent,
}) async =>
(await inspectDiscussions([discussionId], isCurrent: isCurrent))?.single;

/// Stages every selected thread before replacing any loaded cache entry.
Future<List<Discussion>?> inspectDiscussions(
List<String> discussionIds, {
bool Function()? isCurrent,
}) async {
if (_posting || _loadingMore || isCurrent?.call() == false) return null;
final ids = List<String>.unmodifiable(discussionIds);
if (_posting ||
_loadingMore ||
isCurrent?.call() == false ||
ids.isEmpty ||
ids.toSet().length != ids.length) {
return null;
}
final generation = _generation;
_posting = true;
bool current() => generation == _generation && isCurrent?.call() != false;
Expand All @@ -229,21 +313,15 @@ class MrDiscussionsController
if (repo == null) {
throw const GitLabAuthException('No authenticated account.');
}
final fresh = await repo.discussion(
projectId: arg.projectId,
iid: arg.iid,
discussionId: discussionId,
);
if (!current()) return null;
if (fresh.id != discussionId) {
throw const GitLabServerException('Invalid discussion response.');
}
final fresh = await _readDiscussions(repo, ids, current);
if (!current() || fresh == null) return null;
final page = state.valueOrNull;
if (page != null && !state.isLoading && !state.hasError) {
final byId = {for (final g in fresh) g.id: g};
state = AsyncData(
Paginated(
items: List<Discussion>.unmodifiable(
page.items.map((g) => g.id == discussionId ? fresh : g),
page.items.map((g) => byId[g.id] ?? g),
),
nextPage: page.nextPage,
total: page.total,
Expand All @@ -260,6 +338,28 @@ class MrDiscussionsController
}
}

Future<List<Discussion>?> _readDiscussions(
CommentsRepository repo,
List<String> ids,
bool Function() isCurrent,
) async {
final result = <Discussion>[];
for (final id in ids) {
if (!isCurrent()) return null;
final fresh = await repo.discussion(
projectId: arg.projectId,
iid: arg.iid,
discussionId: id,
);
if (!isCurrent()) return null;
if (fresh.id != id) {
throw const GitLabServerException('Invalid discussion response.');
}
result.add(fresh);
}
return List<Discussion>.unmodifiable(result);
}

Future<bool> _post(
String body, {
String? discussionId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import '../controllers/merge_requests_controllers.dart';
import '../controllers/mr_discussions_controller.dart';
import 'mr_discussion_position_context.dart';
import 'mr_suggestion_apply_dialog.dart';
import 'mr_suggestion_batch_apply_dialog.dart';
import 'mr_suggestion_preview.dart';

/// Grouped MR replies with explicit pagination and the shared note composer.
Expand Down Expand Up @@ -202,6 +203,49 @@ class _ConversationState extends ConsumerState<_Conversation> {
}
}

Future<void> _applyBatch(List<SuggestionTarget> targets) async {
if (_applicationId != null ||
_topPosting ||
_replyId != null ||
_resolutionId != null ||
widget.discussions.isLoading ||
widget.discussions.hasError) {
return;
}
final session = ref.read(commentsRepositoryProvider);
final view = ValueNotifier(true);
_applicationView = view;
setState(() => _applicationId = 0);
try {
final applied = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (_) => MrSuggestionBatchApplyDialog(
resource: widget.arg,
targets: targets,
session: session,
isCurrent: () => mounted,
viewActive: view,
),
);
if (mounted &&
applied == true &&
ref.read(commentsRepositoryProvider) == session) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context).mrSuggestionsApplySuccess,
),
),
);
}
} finally {
_applicationView = null;
view.dispose();
if (mounted) setState(() => _applicationId = null);
}
}

@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
Expand Down Expand Up @@ -248,12 +292,29 @@ class _ConversationState extends ConsumerState<_Conversation> {
}
}
}
final targets = applicableSuggestionTargets(page.items);
final groups = page.items
.where((group) => group.notes.any((note) => !note.isSystem))
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (targets.length >= 2)
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton(
key: const ValueKey('mr-suggestions-batch-open'),
onPressed:
canPost &&
_applicationId == null &&
!_topPosting &&
_replyId == null &&
_resolutionId == null
? () => _applyBatch(targets)
: null,
child: Text(l10n.mrSuggestionsBatchButton),
),
),
if (groups.isEmpty)
Text(
page.hasMore ? l10n.mrDiscussionsPartial : l10n.commentsEmpty,
Expand Down
Loading
Loading