diff --git a/apps/labfox/lib/features/merge_requests/data/mr_draft_notes_repository.dart b/apps/labfox/lib/features/merge_requests/data/mr_draft_notes_repository.dart index 06523cb7..91114bc4 100644 --- a/apps/labfox/lib/features/merge_requests/data/mr_draft_notes_repository.dart +++ b/apps/labfox/lib/features/merge_requests/data/mr_draft_notes_repository.dart @@ -102,13 +102,33 @@ class MrDraftNotesRepository { required int projectId, required int iid, required int mergeRequestId, + String? summaryNote, + ReviewerSubmissionState? reviewerState, }) async { if (mergeRequestId < 1) { throw ArgumentError.value(mergeRequestId, 'mergeRequestId'); } - await _client.mergeRequests.publishDraftNotes(projectId, iid: iid); + await _client.mergeRequests.publishDraftNotes( + projectId, + iid: iid, + summaryNote: summaryNote, + reviewerState: reviewerState, + ); } + /// Captures reviewer reads on the same authenticated client as publication. + Future> reviewers({ + required int projectId, + required int iid, + int page = 1, + int perPage = 20, + }) => _client.mergeRequests.reviewers( + projectId, + iid: iid, + page: page, + perPage: perPage, + ); + /// Only regular drafts and complete original text anchors can be edited. static bool canUpdate(MergeRequestDraftNote draft) { try { diff --git a/apps/labfox/lib/features/merge_requests/presentation/controllers/mr_discussions_controller.dart b/apps/labfox/lib/features/merge_requests/presentation/controllers/mr_discussions_controller.dart index d442df32..d7843717 100644 --- a/apps/labfox/lib/features/merge_requests/presentation/controllers/mr_discussions_controller.dart +++ b/apps/labfox/lib/features/merge_requests/presentation/controllers/mr_discussions_controller.dart @@ -293,6 +293,7 @@ class MrDiscussionsController Account? _pendingAccount; bool _pendingNeedsInspection = false; bool _pendingPublicationNeedsInspection = false; + bool _pendingReviewerStateNeedsInspection = false; Future? _pendingWriteSettled; void _syncPendingSession() { @@ -302,6 +303,7 @@ class MrDiscussionsController if (account != _pendingAccount) { _pendingNeedsInspection = false; _pendingPublicationNeedsInspection = false; + _pendingReviewerStateNeedsInspection = false; _pendingWriteSettled = null; _pendingAccount = account; } @@ -323,7 +325,11 @@ class MrDiscussionsController MergeRequest detail, List items, { int? draftNoteId, + bool reviewerStateAvailable = false, + MergeRequestReviewer? reviewer, }) => MrPendingReviewPublication._( + reviewerStateAvailable: reviewerStateAvailable, + reviewer: reviewer, items: items, draftNoteId: draftNoteId, account: ref.read(currentAccountProvider)!, @@ -352,15 +358,69 @@ class MrDiscussionsController ref.read(commentsRepositoryProvider).valueOrNull, ); + Future _readCurrentReviewer( + MrDraftNotesRepository repository, + bool Function() current, + _PendingReviewWait wait, + ) async { + final ids = {}; + MergeRequestReviewer? own; + int? page = 1; + while (page != null) { + if (!current()) throw const _PendingReviewCancelled(); + final result = await wait( + repository.reviewers( + projectId: arg.projectId, + iid: arg.iid, + page: page, + ), + ); + if (!current()) throw const _PendingReviewCancelled(); + if (result.nextPage != null && result.nextPage! <= page) { + throw const GitLabServerException('Invalid reviewers pagination.'); + } + for (final reviewer in result.items) { + if (reviewer.user.id < 1 || + reviewer.state.trim().isEmpty || + !ids.add(reviewer.user.id)) { + throw const GitLabServerException('Invalid reviewers identity.'); + } + if (reviewer.user.id == repository.authorId) own = reviewer; + } + page = result.nextPage; + } + await wait(Future.value()); + if (!current()) throw const _PendingReviewCancelled(); + return own; + } + /// Preparation reads every private page and never clears uncertainty. Future preparePendingReviewPublication({ + bool includeReviewerState = false, bool Function()? isCurrent, }) { if (pendingSaveNeedsInspection) return Future.value(null); return _pendingReview((repository, detail, current, wait) async { final items = await _readPendingNotes(repository, detail, current, wait); + MergeRequestReviewer? reviewer; + var available = false; + if (includeReviewerState) { + try { + reviewer = await _readCurrentReviewer(repository, current, wait); + available = true; + } on GitLabException { + // Older or restricted instances can still publish notes and a summary. + } + } + await wait(Future.value()); if (!current()) throw const _PendingReviewCancelled(); - return _publicationSnapshot(repository, detail, items); + return _publicationSnapshot( + repository, + detail, + items, + reviewerStateAvailable: available, + reviewer: reviewer, + ); }, isCurrent: isCurrent); } @@ -443,11 +503,17 @@ class MrDiscussionsController /// This is a preflight comparison, not a conditional server-side transaction. Future publishPendingReview( MrPendingReviewPublication snapshot, { + String? summaryNote, + ReviewerSubmissionState? reviewerState, bool Function()? isCurrent, }) async { + if (summaryNote != null && summaryNote.trim().isEmpty) return false; if (pendingSaveNeedsInspection || snapshot._draftNoteId != null || - snapshot.items.isEmpty || + (snapshot.items.isEmpty && + summaryNote == null && + reviewerState == null) || + (reviewerState != null && !snapshot.reviewerStateAvailable) || !_currentPublication(snapshot)) { return false; } @@ -468,22 +534,53 @@ class MrDiscussionsController 'The pending review changed. Inspect it before publishing.', ); } + if (reviewerState != null) { + final reviewer = await _readCurrentReviewer( + repository, + current, + wait, + ); + if (reviewer != snapshot.reviewer || + (reviewerState == ReviewerSubmissionState.reviewed && + reviewer?.state == 'approved')) { + throw const GitLabConflictException( + 'The reviewer state changed or cannot be replaced. Inspect before submitting.', + ); + } + } await wait(Future.value()); if (!current()) throw const _PendingReviewCancelled(); _pendingPublicationNeedsInspection = true; _pendingNeedsInspection = true; + _pendingReviewerStateNeedsInspection = reviewerState != null; final write = repository.publish( projectId: arg.projectId, iid: arg.iid, mergeRequestId: detail.id, + summaryNote: summaryNote, + reviewerState: reviewerState, ); _pendingWriteSettled = write.then( (_) {}, onError: (Object _, StackTrace _) {}, ); await wait(write); + if (reviewerState != null) { + final reviewer = await _readCurrentReviewer( + repository, + current, + wait, + ); + if (reviewer?.state != reviewerState.value) { + throw const GitLabServerException( + 'Review submission state was not confirmed. Inspect before retrying.', + ); + } + } + await wait(Future.value()); if (!current()) throw const _PendingReviewCancelled(); _pendingPublicationNeedsInspection = false; + _pendingReviewerStateNeedsInspection = false; _pendingNeedsInspection = false; _pendingWriteSettled = null; ref.read(mrDraftNotesRevisionProvider(arg).notifier).state++; @@ -507,6 +604,7 @@ class MrDiscussionsController /// Their text or absence cannot identify which uncertain attempt succeeded. Future inspectPendingReviewPublication({ + bool includeReviewerState = false, bool Function()? isCurrent, }) => _pendingReview((repository, detail, current, wait) async { final unsettled = _pendingWriteSettled; @@ -539,14 +637,31 @@ class MrDiscussionsController groups.addAll(result.items); page = result.nextPage; } + MergeRequestReviewer? reviewer; + var available = false; + if (includeReviewerState || _pendingReviewerStateNeedsInspection) { + try { + reviewer = await _readCurrentReviewer(repository, current, wait); + available = true; + } on GitLabException { + if (_pendingReviewerStateNeedsInspection) rethrow; + } + } await wait(Future.value()); if (!current()) throw const _PendingReviewCancelled(); final inspection = MrPendingReviewPublicationInspection._( - _publicationSnapshot(repository, detail, items), + _publicationSnapshot( + repository, + detail, + items, + reviewerStateAvailable: available, + reviewer: reviewer, + ), groups, ); _pendingNeedsInspection = false; _pendingPublicationNeedsInspection = false; + _pendingReviewerStateNeedsInspection = false; _pendingWriteSettled = null; ref.read(mrDraftNotesRevisionProvider(arg).notifier).state++; state = AsyncData(Paginated(items: inspection.publicDiscussions)); @@ -1171,6 +1286,8 @@ typedef _PendingReviewWait = Future Function(Future future); /// Immutable confirmation data bound to the account, repositories and MR. class MrPendingReviewPublication { MrPendingReviewPublication._({ + this.reviewerStateAvailable = false, + this.reviewer, required List items, int? draftNoteId, required Account account, @@ -1198,6 +1315,8 @@ class MrPendingReviewPublication { return MrPendingReviewPublication._( items: [matches.single], draftNoteId: draftNoteId, + reviewerStateAvailable: reviewerStateAvailable, + reviewer: reviewer, account: _account, client: _client, drafts: _drafts, @@ -1208,6 +1327,8 @@ class MrPendingReviewPublication { ); } + final bool reviewerStateAvailable; + final MergeRequestReviewer? reviewer; final int? _draftNoteId; final List items; final Account _account; diff --git a/apps/labfox/lib/features/merge_requests/presentation/widgets/mr_pending_review_publisher.dart b/apps/labfox/lib/features/merge_requests/presentation/widgets/mr_pending_review_publisher.dart index dff768f9..57a91bda 100644 --- a/apps/labfox/lib/features/merge_requests/presentation/widgets/mr_pending_review_publisher.dart +++ b/apps/labfox/lib/features/merge_requests/presentation/widgets/mr_pending_review_publisher.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gitlab_api/gitlab_api.dart'; import 'package:gitlab_models/gitlab_models.dart'; import '../../../../core/auth/auth_controller.dart'; @@ -177,6 +178,13 @@ class _PublicationState extends ConsumerState<_PublicationDialog> { Object? _clientSession, _commentsSession; bool _obsolete = false, _started = false, _busy = false, _writing = false; bool _needsInspection = false, _acknowledged = false, _readError = false; + final _summary = TextEditingController(); + ReviewerSubmissionState? _reviewerState; + bool get _hasOptions => + widget.draft == null && + (_summary.text.trim().isNotEmpty || _reviewerState != null); + bool get _hasSubmission => + _snapshot != null && (_snapshot!.items.isNotEmpty || _hasOptions); MrPendingReviewPublication? _snapshot; MrPendingReviewPublicationInspection? _inspection; @override @@ -188,6 +196,7 @@ class _PublicationState extends ConsumerState<_PublicationDialog> { @override void dispose() { widget.viewActive.removeListener(_observe); + _summary.dispose(); super.dispose(); } @@ -221,6 +230,8 @@ class _PublicationState extends ConsumerState<_PublicationDialog> { if (!mounted || _obsolete) return; setState(() { _obsolete = true; + _summary.clear(); + _reviewerState = null; _snapshot = null; _inspection = null; _acknowledged = false; @@ -248,6 +259,7 @@ class _PublicationState extends ConsumerState<_PublicationDialog> { } if (recovery) { final inspection = await _controller.inspectPendingReviewPublication( + includeReviewerState: widget.draft == null, isCurrent: _current, ); if (!_current()) return; @@ -260,10 +272,12 @@ class _PublicationState extends ConsumerState<_PublicationDialog> { _snapshot = widget.draft == null ? inspection.pending : inspection.pending.forNote(widget.draft!.id); + _reconcileOutcome(); }); } else { final snapshot = widget.draft == null ? await _controller.preparePendingReviewPublication( + includeReviewerState: true, isCurrent: _current, ) : await _controller.preparePendingNotePublication( @@ -273,6 +287,7 @@ class _PublicationState extends ConsumerState<_PublicationDialog> { if (!_current()) return; setState(() { _snapshot = snapshot; + _reconcileOutcome(); _readError = snapshot == null; }); } @@ -283,16 +298,31 @@ class _PublicationState extends ConsumerState<_PublicationDialog> { } } + void _reconcileOutcome() { + final snapshot = _snapshot; + if (snapshot != null && + (!snapshot.reviewerStateAvailable || + (_reviewerState == ReviewerSubmissionState.reviewed && + snapshot.reviewer?.state == 'approved'))) { + // A newly inspected preview cannot keep an unavailable/disabled outcome. + _reviewerState = null; + } + } + Future _publish() async { final snapshot = _snapshot; if (_busy || !_current() || !_acknowledged || snapshot == null || - snapshot.items.isEmpty || + !_hasSubmission || _controller.pendingSaveNeedsInspection) { return; } + final summary = widget.draft == null && _summary.text.trim().isNotEmpty + ? _summary.text + : null; + final outcome = _reviewerState; setState(() { _busy = true; _writing = true; @@ -304,6 +334,8 @@ class _PublicationState extends ConsumerState<_PublicationDialog> { final published = widget.draft == null ? await _controller.publishPendingReview( snapshot, + summaryNote: summary, + reviewerState: outcome, isCurrent: _current, ) : await _controller.publishPendingNote(snapshot, isCurrent: _current); @@ -325,6 +357,18 @@ class _PublicationState extends ConsumerState<_PublicationDialog> { } } + String _reviewLabel(AppLocalizations l, MergeRequestReviewer? reviewer) => + switch (reviewer?.state) { + null => l.mrReviewUnassigned, + 'unreviewed' => l.mrReviewUnreviewed, + 'review_started' => l.mrReviewStarted, + 'reviewed' => l.mrReviewReviewed, + 'requested_changes' => l.mrReviewRequestChanges, + 'approved' => l.mrReviewApproved, + 'unapproved' => l.mrReviewUnapproved, + _ => l.mrReviewUnknownState, + }; + @override Widget build(BuildContext context) { final l = AppLocalizations.of(context); @@ -372,7 +416,10 @@ class _PublicationState extends ConsumerState<_PublicationDialog> { !_busy && !gated && _snapshot != null && - _snapshot!.items.isNotEmpty && + _hasSubmission && + (_reviewerState == null || _snapshot!.reviewerStateAvailable) && + !(_reviewerState == ReviewerSubmissionState.reviewed && + _snapshot!.reviewer?.state == 'approved') && _acknowledged; return PopScope( canPop: !_writing, @@ -406,6 +453,7 @@ class _PublicationState extends ConsumerState<_PublicationDialog> { if (_needsInspection) ...[ const SizedBox(height: LabFoxSpacing.md), Text(l.mrPendingPublishUncertain), + if (widget.draft == null) Text(l.mrReviewPartialHint), ], if (_busy || (current && @@ -452,7 +500,71 @@ class _PublicationState extends ConsumerState<_PublicationDialog> { SelectableText(note.body), ], ], - if (_snapshot != null && _snapshot!.items.isNotEmpty) + if ((_snapshot ?? _inspection?.pending) + case final preview?) ...[ + const SizedBox(height: LabFoxSpacing.md), + if (preview.reviewerStateAvailable) ...[ + Text( + l.mrReviewCurrentState, + style: Theme.of(context).textTheme.titleSmall, + ), + Text(_reviewLabel(l, preview.reviewer)), + ] else if (widget.draft == null) + Text(l.mrReviewStateUnavailable), + ], + if (widget.draft == null) ...[ + const SizedBox(height: LabFoxSpacing.md), + TextField( + key: const ValueKey('mr-review-summary'), + controller: _summary, + minLines: 2, + maxLines: 5, + enabled: !_busy && !gated && _snapshot != null, + decoration: InputDecoration( + labelText: l.mrReviewSummaryLabel, + ), + onChanged: (_) => setState(() => _acknowledged = false), + ), + const SizedBox(height: LabFoxSpacing.md), + DropdownButtonFormField( + key: const ValueKey('mr-review-state'), + initialValue: _reviewerState, + isExpanded: true, + decoration: InputDecoration( + labelText: l.mrReviewOutcomeLabel, + ), + items: [ + DropdownMenuItem( + value: null, + child: Text(l.mrReviewKeepState), + ), + DropdownMenuItem( + value: ReviewerSubmissionState.reviewed, + enabled: _snapshot?.reviewer?.state != 'approved', + child: Text(l.mrReviewReviewed), + ), + DropdownMenuItem( + value: ReviewerSubmissionState.requestedChanges, + child: Text(l.mrReviewRequestChanges), + ), + ], + onChanged: + _busy || + gated || + _snapshot?.reviewerStateAvailable != true + ? null + : (value) => setState(() { + _reviewerState = value; + _acknowledged = false; + }), + ), + if (_reviewerState == ReviewerSubmissionState.reviewed) + Text(l.mrReviewReviewedHint), + if (_reviewerState == + ReviewerSubmissionState.requestedChanges) + Text(l.mrReviewChangesHint), + ], + if (_hasSubmission) CheckboxListTile( key: const ValueKey('mr-pending-publish-acknowledge'), contentPadding: EdgeInsets.zero, @@ -463,7 +575,9 @@ class _PublicationState extends ConsumerState<_PublicationDialog> { setState(() => _acknowledged = value == true), title: Text( widget.draft == null - ? l.mrPendingPublishConsent + ? (_hasOptions + ? l.mrReviewSubmissionConsent + : l.mrPendingPublishConsent) : l.mrPendingPublishNoteConsent, ), controlAffinity: ListTileControlAffinity.leading, @@ -488,14 +602,20 @@ class _PublicationState extends ConsumerState<_PublicationDialog> { onPressed: dependenciesReady && !_busy ? () => _read(recovery: true) : null, - child: Text(l.mrPendingPublishInspect), + child: Text( + widget.draft == null + ? l.mrReviewInspectButton + : l.mrPendingPublishInspect, + ), ), FilledButton( key: const ValueKey('mr-pending-publish-submit'), onPressed: canPublish ? _publish : null, child: Text( widget.draft == null - ? l.mrPendingPublishButton + ? (_hasOptions + ? l.mrReviewSubmitButton + : l.mrPendingPublishButton) : l.mrPendingPublishNoteButton, ), ), diff --git a/apps/labfox/lib/l10n/app_en.arb b/apps/labfox/lib/l10n/app_en.arb index 5dbb5a81..25c36095 100644 --- a/apps/labfox/lib/l10n/app_en.arb +++ b/apps/labfox/lib/l10n/app_en.arb @@ -2992,5 +2992,24 @@ "mrPendingPublishNoteHint": "This saved note will become visible to others in this merge request. Your other pending notes stay private.", "mrPendingPublishNoteRaceHint": "The selected note is checked again before publishing. Changes from another client after that check may be included.", "mrPendingPublishNoteConsent": "I want to make this saved note public.", - "mrPendingNotePublished": "Review note published." + "mrPendingNotePublished": "Review note published.", + "mrReviewSummaryLabel": "Public review summary (optional)", + "mrReviewOutcomeLabel": "Review outcome", + "mrReviewKeepState": "Keep current state", + "mrReviewReviewed": "Reviewed", + "mrReviewRequestChanges": "Request changes", + "mrReviewCurrentState": "Your current reviewer state", + "mrReviewUnassigned": "Not assigned as a reviewer", + "mrReviewUnreviewed": "Not reviewed", + "mrReviewStarted": "Review started", + "mrReviewApproved": "Formally approved", + "mrReviewUnknownState": "Unrecognized reviewer state", + "mrReviewStateUnavailable": "Reviewer state could not be read. You can still publish notes and a public summary.", + "mrReviewReviewedHint": "Reviewed does not approve this merge request. It may clear your previous change request.", + "mrReviewChangesHint": "Requesting changes may block merging and remove your existing approval.", + "mrReviewPartialHint": "Notes, the summary, and reviewer state may have been applied separately. Inspect all three before deciding whether to submit again.", + "mrReviewSubmissionConsent": "Publish the displayed pending notes and public summary, and apply the selected review outcome.", + "mrReviewUnapproved": "Approval withdrawn", + "mrReviewSubmitButton": "Submit review", + "mrReviewInspectButton": "Inspect review" } diff --git a/apps/labfox/lib/l10n/app_hi.arb b/apps/labfox/lib/l10n/app_hi.arb index aa5cb8b9..db706a15 100644 --- a/apps/labfox/lib/l10n/app_hi.arb +++ b/apps/labfox/lib/l10n/app_hi.arb @@ -1435,5 +1435,24 @@ "mrPendingPublishNoteHint": "यह सहेजा गया नोट इस मर्ज अनुरोध में दूसरों को दिखाई देगा। आपके अन्य लंबित नोट निजी रहेंगे।", "mrPendingPublishNoteRaceHint": "प्रकाशित करने से पहले चयनित नोट फिर से जाँचा जाता है। उस जाँच के बाद किसी अन्य क्लाइंट द्वारा किए गए बदलाव शामिल हो सकते हैं।", "mrPendingPublishNoteConsent": "मैं इस सहेजे गए नोट को सार्वजनिक करना चाहता हूँ।", - "mrPendingNotePublished": "समीक्षा नोट प्रकाशित हुआ।" + "mrPendingNotePublished": "समीक्षा नोट प्रकाशित हुआ।", + "mrReviewSummaryLabel": "सार्वजनिक समीक्षा सारांश (वैकल्पिक)", + "mrReviewOutcomeLabel": "समीक्षा परिणाम", + "mrReviewKeepState": "वर्तमान स्थिति बनाए रखें", + "mrReviewReviewed": "समीक्षा की गई", + "mrReviewRequestChanges": "बदलाव का अनुरोध करें", + "mrReviewCurrentState": "आपकी वर्तमान समीक्षक स्थिति", + "mrReviewUnassigned": "समीक्षक के रूप में नियुक्त नहीं", + "mrReviewUnreviewed": "समीक्षा नहीं की गई", + "mrReviewStarted": "समीक्षा शुरू हुई", + "mrReviewApproved": "औपचारिक रूप से स्वीकृत", + "mrReviewUnknownState": "अपरिचित समीक्षक स्थिति", + "mrReviewStateUnavailable": "समीक्षक स्थिति पढ़ी नहीं जा सकी। आप नोट और सार्वजनिक सारांश प्रकाशित कर सकते हैं।", + "mrReviewReviewedHint": "समीक्षा की गई चुनने से मर्ज अनुरोध स्वीकृत नहीं होता। आपका पिछला बदलाव अनुरोध हट सकता है।", + "mrReviewChangesHint": "बदलाव का अनुरोध मर्ज रोक सकता है और आपकी मौजूदा स्वीकृति हटा सकता है।", + "mrReviewPartialHint": "नोट, सारांश और समीक्षक स्थिति अलग-अलग लागू हुए हो सकते हैं। दोबारा भेजने से पहले तीनों की जाँच करें।", + "mrReviewSubmissionConsent": "दिखाए गए लंबित नोट और सार्वजनिक सारांश प्रकाशित करें और चुना गया समीक्षा परिणाम लागू करें।", + "mrReviewUnapproved": "स्वीकृति वापस ली गई", + "mrReviewSubmitButton": "समीक्षा भेजें", + "mrReviewInspectButton": "समीक्षा की जाँच करें" } diff --git a/apps/labfox/lib/l10n/app_ja.arb b/apps/labfox/lib/l10n/app_ja.arb index ea3c1e33..7dd5ed3e 100644 --- a/apps/labfox/lib/l10n/app_ja.arb +++ b/apps/labfox/lib/l10n/app_ja.arb @@ -1435,5 +1435,24 @@ "mrPendingPublishNoteHint": "この保存済みの下書きがマージリクエストの他のユーザーに表示されます。他の下書きは非公開のままです。", "mrPendingPublishNoteRaceHint": "公開前に選択した下書きを再確認します。確認後に別のクライアントで変更された内容が含まれる場合があります。", "mrPendingPublishNoteConsent": "この保存済みの下書きを公開します。", - "mrPendingNotePublished": "レビューの下書きを公開しました。" + "mrPendingNotePublished": "レビューの下書きを公開しました。", + "mrReviewSummaryLabel": "公開レビューの概要(任意)", + "mrReviewOutcomeLabel": "レビュー結果", + "mrReviewKeepState": "現在の状態を維持", + "mrReviewReviewed": "レビュー済み", + "mrReviewRequestChanges": "変更を要求", + "mrReviewCurrentState": "現在のレビュー状態", + "mrReviewUnassigned": "レビュアー未割り当て", + "mrReviewUnreviewed": "未レビュー", + "mrReviewStarted": "レビュー開始済み", + "mrReviewApproved": "正式に承認済み", + "mrReviewUnknownState": "未対応のレビュー状態", + "mrReviewStateUnavailable": "レビュー状態を取得できません。ノートと公開概要は投稿できます。", + "mrReviewReviewedHint": "レビュー済みはマージリクエストの承認ではありません。以前の変更要求が解除される場合があります。", + "mrReviewChangesHint": "変更要求によりマージがブロックされ、既存の承認が取り消される場合があります。", + "mrReviewPartialHint": "ノート、概要、レビュー状態が個別に反映された可能性があります。再送信の前にすべて確認してください。", + "mrReviewSubmissionConsent": "表示された保留ノートと公開概要を投稿し、選択したレビュー結果を適用します。", + "mrReviewUnapproved": "承認取り消し済み", + "mrReviewSubmitButton": "レビューを送信", + "mrReviewInspectButton": "レビューを確認" } diff --git a/apps/labfox/lib/l10n/app_ko.arb b/apps/labfox/lib/l10n/app_ko.arb index c00851c2..99649cf8 100644 --- a/apps/labfox/lib/l10n/app_ko.arb +++ b/apps/labfox/lib/l10n/app_ko.arb @@ -1435,5 +1435,24 @@ "mrPendingPublishNoteHint": "저장된 이 초안이 머지 리퀘스트의 다른 사람에게 공개됩니다. 다른 초안은 비공개로 유지됩니다.", "mrPendingPublishNoteRaceHint": "게시 전에 선택한 초안을 다시 확인합니다. 확인 이후 다른 클라이언트에서 변경한 내용이 포함될 수 있습니다.", "mrPendingPublishNoteConsent": "저장된 이 초안을 공개하겠습니다.", - "mrPendingNotePublished": "리뷰 초안을 게시했습니다." + "mrPendingNotePublished": "리뷰 초안을 게시했습니다.", + "mrReviewSummaryLabel": "공개 리뷰 요약(선택 사항)", + "mrReviewOutcomeLabel": "리뷰 결과", + "mrReviewKeepState": "현재 상태 유지", + "mrReviewReviewed": "검토 완료", + "mrReviewRequestChanges": "변경 요청", + "mrReviewCurrentState": "내 현재 리뷰어 상태", + "mrReviewUnassigned": "리뷰어로 지정되지 않음", + "mrReviewUnreviewed": "아직 검토하지 않음", + "mrReviewStarted": "검토 시작됨", + "mrReviewApproved": "정식 승인됨", + "mrReviewUnknownState": "알 수 없는 리뷰어 상태", + "mrReviewStateUnavailable": "리뷰어 상태를 읽을 수 없습니다. 초안과 공개 요약은 게시할 수 있습니다.", + "mrReviewReviewedHint": "검토 완료는 이 머지 리퀘스트의 승인이 아닙니다. 이전 변경 요청을 해제할 수 있습니다.", + "mrReviewChangesHint": "변경 요청은 머지를 막고 기존 승인을 취소할 수 있습니다.", + "mrReviewPartialHint": "초안, 요약, 리뷰어 상태가 각각 적용되었을 수 있습니다. 다시 제출할지 결정하기 전에 세 항목을 모두 확인하세요.", + "mrReviewSubmissionConsent": "표시된 초안과 공개 요약을 게시하고 선택한 리뷰 결과를 적용합니다.", + "mrReviewUnapproved": "승인 취소됨", + "mrReviewSubmitButton": "리뷰 제출", + "mrReviewInspectButton": "리뷰 확인" } diff --git a/apps/labfox/lib/l10n/app_localizations.dart b/apps/labfox/lib/l10n/app_localizations.dart index 5cdd3b14..097b47d3 100644 --- a/apps/labfox/lib/l10n/app_localizations.dart +++ b/apps/labfox/lib/l10n/app_localizations.dart @@ -9181,6 +9181,120 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Review note published.'** String get mrPendingNotePublished; + + /// No description provided for @mrReviewSummaryLabel. + /// + /// In en, this message translates to: + /// **'Public review summary (optional)'** + String get mrReviewSummaryLabel; + + /// No description provided for @mrReviewOutcomeLabel. + /// + /// In en, this message translates to: + /// **'Review outcome'** + String get mrReviewOutcomeLabel; + + /// No description provided for @mrReviewKeepState. + /// + /// In en, this message translates to: + /// **'Keep current state'** + String get mrReviewKeepState; + + /// No description provided for @mrReviewReviewed. + /// + /// In en, this message translates to: + /// **'Reviewed'** + String get mrReviewReviewed; + + /// No description provided for @mrReviewRequestChanges. + /// + /// In en, this message translates to: + /// **'Request changes'** + String get mrReviewRequestChanges; + + /// No description provided for @mrReviewCurrentState. + /// + /// In en, this message translates to: + /// **'Your current reviewer state'** + String get mrReviewCurrentState; + + /// No description provided for @mrReviewUnassigned. + /// + /// In en, this message translates to: + /// **'Not assigned as a reviewer'** + String get mrReviewUnassigned; + + /// No description provided for @mrReviewUnreviewed. + /// + /// In en, this message translates to: + /// **'Not reviewed'** + String get mrReviewUnreviewed; + + /// No description provided for @mrReviewStarted. + /// + /// In en, this message translates to: + /// **'Review started'** + String get mrReviewStarted; + + /// No description provided for @mrReviewApproved. + /// + /// In en, this message translates to: + /// **'Formally approved'** + String get mrReviewApproved; + + /// No description provided for @mrReviewUnknownState. + /// + /// In en, this message translates to: + /// **'Unrecognized reviewer state'** + String get mrReviewUnknownState; + + /// No description provided for @mrReviewStateUnavailable. + /// + /// In en, this message translates to: + /// **'Reviewer state could not be read. You can still publish notes and a public summary.'** + String get mrReviewStateUnavailable; + + /// No description provided for @mrReviewReviewedHint. + /// + /// In en, this message translates to: + /// **'Reviewed does not approve this merge request. It may clear your previous change request.'** + String get mrReviewReviewedHint; + + /// No description provided for @mrReviewChangesHint. + /// + /// In en, this message translates to: + /// **'Requesting changes may block merging and remove your existing approval.'** + String get mrReviewChangesHint; + + /// No description provided for @mrReviewPartialHint. + /// + /// In en, this message translates to: + /// **'Notes, the summary, and reviewer state may have been applied separately. Inspect all three before deciding whether to submit again.'** + String get mrReviewPartialHint; + + /// No description provided for @mrReviewSubmissionConsent. + /// + /// In en, this message translates to: + /// **'Publish the displayed pending notes and public summary, and apply the selected review outcome.'** + String get mrReviewSubmissionConsent; + + /// No description provided for @mrReviewUnapproved. + /// + /// In en, this message translates to: + /// **'Approval withdrawn'** + String get mrReviewUnapproved; + + /// No description provided for @mrReviewSubmitButton. + /// + /// In en, this message translates to: + /// **'Submit review'** + String get mrReviewSubmitButton; + + /// No description provided for @mrReviewInspectButton. + /// + /// In en, this message translates to: + /// **'Inspect review'** + String get mrReviewInspectButton; } class _AppLocalizationsDelegate diff --git a/apps/labfox/lib/l10n/app_localizations_en.dart b/apps/labfox/lib/l10n/app_localizations_en.dart index b1ce4f97..94988b68 100644 --- a/apps/labfox/lib/l10n/app_localizations_en.dart +++ b/apps/labfox/lib/l10n/app_localizations_en.dart @@ -5237,4 +5237,66 @@ class AppLocalizationsEn extends AppLocalizations { @override String get mrPendingNotePublished => 'Review note published.'; + + @override + String get mrReviewSummaryLabel => 'Public review summary (optional)'; + + @override + String get mrReviewOutcomeLabel => 'Review outcome'; + + @override + String get mrReviewKeepState => 'Keep current state'; + + @override + String get mrReviewReviewed => 'Reviewed'; + + @override + String get mrReviewRequestChanges => 'Request changes'; + + @override + String get mrReviewCurrentState => 'Your current reviewer state'; + + @override + String get mrReviewUnassigned => 'Not assigned as a reviewer'; + + @override + String get mrReviewUnreviewed => 'Not reviewed'; + + @override + String get mrReviewStarted => 'Review started'; + + @override + String get mrReviewApproved => 'Formally approved'; + + @override + String get mrReviewUnknownState => 'Unrecognized reviewer state'; + + @override + String get mrReviewStateUnavailable => + 'Reviewer state could not be read. You can still publish notes and a public summary.'; + + @override + String get mrReviewReviewedHint => + 'Reviewed does not approve this merge request. It may clear your previous change request.'; + + @override + String get mrReviewChangesHint => + 'Requesting changes may block merging and remove your existing approval.'; + + @override + String get mrReviewPartialHint => + 'Notes, the summary, and reviewer state may have been applied separately. Inspect all three before deciding whether to submit again.'; + + @override + String get mrReviewSubmissionConsent => + 'Publish the displayed pending notes and public summary, and apply the selected review outcome.'; + + @override + String get mrReviewUnapproved => 'Approval withdrawn'; + + @override + String get mrReviewSubmitButton => 'Submit review'; + + @override + String get mrReviewInspectButton => 'Inspect review'; } diff --git a/apps/labfox/lib/l10n/app_localizations_hi.dart b/apps/labfox/lib/l10n/app_localizations_hi.dart index 46e00b7a..66f18703 100644 --- a/apps/labfox/lib/l10n/app_localizations_hi.dart +++ b/apps/labfox/lib/l10n/app_localizations_hi.dart @@ -5234,4 +5234,66 @@ class AppLocalizationsHi extends AppLocalizations { @override String get mrPendingNotePublished => 'समीक्षा नोट प्रकाशित हुआ।'; + + @override + String get mrReviewSummaryLabel => 'सार्वजनिक समीक्षा सारांश (वैकल्पिक)'; + + @override + String get mrReviewOutcomeLabel => 'समीक्षा परिणाम'; + + @override + String get mrReviewKeepState => 'वर्तमान स्थिति बनाए रखें'; + + @override + String get mrReviewReviewed => 'समीक्षा की गई'; + + @override + String get mrReviewRequestChanges => 'बदलाव का अनुरोध करें'; + + @override + String get mrReviewCurrentState => 'आपकी वर्तमान समीक्षक स्थिति'; + + @override + String get mrReviewUnassigned => 'समीक्षक के रूप में नियुक्त नहीं'; + + @override + String get mrReviewUnreviewed => 'समीक्षा नहीं की गई'; + + @override + String get mrReviewStarted => 'समीक्षा शुरू हुई'; + + @override + String get mrReviewApproved => 'औपचारिक रूप से स्वीकृत'; + + @override + String get mrReviewUnknownState => 'अपरिचित समीक्षक स्थिति'; + + @override + String get mrReviewStateUnavailable => + 'समीक्षक स्थिति पढ़ी नहीं जा सकी। आप नोट और सार्वजनिक सारांश प्रकाशित कर सकते हैं।'; + + @override + String get mrReviewReviewedHint => + 'समीक्षा की गई चुनने से मर्ज अनुरोध स्वीकृत नहीं होता। आपका पिछला बदलाव अनुरोध हट सकता है।'; + + @override + String get mrReviewChangesHint => + 'बदलाव का अनुरोध मर्ज रोक सकता है और आपकी मौजूदा स्वीकृति हटा सकता है।'; + + @override + String get mrReviewPartialHint => + 'नोट, सारांश और समीक्षक स्थिति अलग-अलग लागू हुए हो सकते हैं। दोबारा भेजने से पहले तीनों की जाँच करें।'; + + @override + String get mrReviewSubmissionConsent => + 'दिखाए गए लंबित नोट और सार्वजनिक सारांश प्रकाशित करें और चुना गया समीक्षा परिणाम लागू करें।'; + + @override + String get mrReviewUnapproved => 'स्वीकृति वापस ली गई'; + + @override + String get mrReviewSubmitButton => 'समीक्षा भेजें'; + + @override + String get mrReviewInspectButton => 'समीक्षा की जाँच करें'; } diff --git a/apps/labfox/lib/l10n/app_localizations_ja.dart b/apps/labfox/lib/l10n/app_localizations_ja.dart index 398c2ffc..641acd42 100644 --- a/apps/labfox/lib/l10n/app_localizations_ja.dart +++ b/apps/labfox/lib/l10n/app_localizations_ja.dart @@ -5052,4 +5052,64 @@ class AppLocalizationsJa extends AppLocalizations { @override String get mrPendingNotePublished => 'レビューの下書きを公開しました。'; + + @override + String get mrReviewSummaryLabel => '公開レビューの概要(任意)'; + + @override + String get mrReviewOutcomeLabel => 'レビュー結果'; + + @override + String get mrReviewKeepState => '現在の状態を維持'; + + @override + String get mrReviewReviewed => 'レビュー済み'; + + @override + String get mrReviewRequestChanges => '変更を要求'; + + @override + String get mrReviewCurrentState => '現在のレビュー状態'; + + @override + String get mrReviewUnassigned => 'レビュアー未割り当て'; + + @override + String get mrReviewUnreviewed => '未レビュー'; + + @override + String get mrReviewStarted => 'レビュー開始済み'; + + @override + String get mrReviewApproved => '正式に承認済み'; + + @override + String get mrReviewUnknownState => '未対応のレビュー状態'; + + @override + String get mrReviewStateUnavailable => 'レビュー状態を取得できません。ノートと公開概要は投稿できます。'; + + @override + String get mrReviewReviewedHint => + 'レビュー済みはマージリクエストの承認ではありません。以前の変更要求が解除される場合があります。'; + + @override + String get mrReviewChangesHint => '変更要求によりマージがブロックされ、既存の承認が取り消される場合があります。'; + + @override + String get mrReviewPartialHint => + 'ノート、概要、レビュー状態が個別に反映された可能性があります。再送信の前にすべて確認してください。'; + + @override + String get mrReviewSubmissionConsent => + '表示された保留ノートと公開概要を投稿し、選択したレビュー結果を適用します。'; + + @override + String get mrReviewUnapproved => '承認取り消し済み'; + + @override + String get mrReviewSubmitButton => 'レビューを送信'; + + @override + String get mrReviewInspectButton => 'レビューを確認'; } diff --git a/apps/labfox/lib/l10n/app_localizations_ko.dart b/apps/labfox/lib/l10n/app_localizations_ko.dart index 0336698f..2ab86069 100644 --- a/apps/labfox/lib/l10n/app_localizations_ko.dart +++ b/apps/labfox/lib/l10n/app_localizations_ko.dart @@ -5058,4 +5058,65 @@ class AppLocalizationsKo extends AppLocalizations { @override String get mrPendingNotePublished => '리뷰 초안을 게시했습니다.'; + + @override + String get mrReviewSummaryLabel => '공개 리뷰 요약(선택 사항)'; + + @override + String get mrReviewOutcomeLabel => '리뷰 결과'; + + @override + String get mrReviewKeepState => '현재 상태 유지'; + + @override + String get mrReviewReviewed => '검토 완료'; + + @override + String get mrReviewRequestChanges => '변경 요청'; + + @override + String get mrReviewCurrentState => '내 현재 리뷰어 상태'; + + @override + String get mrReviewUnassigned => '리뷰어로 지정되지 않음'; + + @override + String get mrReviewUnreviewed => '아직 검토하지 않음'; + + @override + String get mrReviewStarted => '검토 시작됨'; + + @override + String get mrReviewApproved => '정식 승인됨'; + + @override + String get mrReviewUnknownState => '알 수 없는 리뷰어 상태'; + + @override + String get mrReviewStateUnavailable => + '리뷰어 상태를 읽을 수 없습니다. 초안과 공개 요약은 게시할 수 있습니다.'; + + @override + String get mrReviewReviewedHint => + '검토 완료는 이 머지 리퀘스트의 승인이 아닙니다. 이전 변경 요청을 해제할 수 있습니다.'; + + @override + String get mrReviewChangesHint => '변경 요청은 머지를 막고 기존 승인을 취소할 수 있습니다.'; + + @override + String get mrReviewPartialHint => + '초안, 요약, 리뷰어 상태가 각각 적용되었을 수 있습니다. 다시 제출할지 결정하기 전에 세 항목을 모두 확인하세요.'; + + @override + String get mrReviewSubmissionConsent => + '표시된 초안과 공개 요약을 게시하고 선택한 리뷰 결과를 적용합니다.'; + + @override + String get mrReviewUnapproved => '승인 취소됨'; + + @override + String get mrReviewSubmitButton => '리뷰 제출'; + + @override + String get mrReviewInspectButton => '리뷰 확인'; } diff --git a/apps/labfox/lib/l10n/app_localizations_zh.dart b/apps/labfox/lib/l10n/app_localizations_zh.dart index 7b6b3b40..8e4effef 100644 --- a/apps/labfox/lib/l10n/app_localizations_zh.dart +++ b/apps/labfox/lib/l10n/app_localizations_zh.dart @@ -4894,4 +4894,61 @@ class AppLocalizationsZh extends AppLocalizations { @override String get mrPendingNotePublished => '评审草稿已发布。'; + + @override + String get mrReviewSummaryLabel => '公开评审摘要(可选)'; + + @override + String get mrReviewOutcomeLabel => '评审结果'; + + @override + String get mrReviewKeepState => '保持当前状态'; + + @override + String get mrReviewReviewed => '已评审'; + + @override + String get mrReviewRequestChanges => '请求更改'; + + @override + String get mrReviewCurrentState => '您当前的评审状态'; + + @override + String get mrReviewUnassigned => '未被指派为评审者'; + + @override + String get mrReviewUnreviewed => '尚未评审'; + + @override + String get mrReviewStarted => '已开始评审'; + + @override + String get mrReviewApproved => '已正式批准'; + + @override + String get mrReviewUnknownState => '无法识别的评审状态'; + + @override + String get mrReviewStateUnavailable => '无法读取评审状态。您仍可发布备注和公开摘要。'; + + @override + String get mrReviewReviewedHint => '已评审不会批准此合并请求,但可能清除您之前的更改请求。'; + + @override + String get mrReviewChangesHint => '请求更改可能阻止合并并撤销您已有的批准。'; + + @override + String get mrReviewPartialHint => '备注、摘要和评审状态可能已分别应用。决定是否再次提交前,请检查这三项。'; + + @override + String get mrReviewSubmissionConsent => '发布显示的待发备注和公开摘要,并应用所选的评审结果。'; + + @override + String get mrReviewUnapproved => '批准已撤回'; + + @override + String get mrReviewSubmitButton => '提交评审'; + + @override + String get mrReviewInspectButton => '检查评审'; } diff --git a/apps/labfox/lib/l10n/app_zh.arb b/apps/labfox/lib/l10n/app_zh.arb index 03cb8c98..5e06ac10 100644 --- a/apps/labfox/lib/l10n/app_zh.arb +++ b/apps/labfox/lib/l10n/app_zh.arb @@ -1435,5 +1435,24 @@ "mrPendingPublishNoteHint": "此已保存的草稿将对合并请求中的其他人可见。其他待发布草稿仍保持私密。", "mrPendingPublishNoteRaceHint": "发布前会再次检查所选草稿。检查之后其他客户端所做的更改可能会包含在内。", "mrPendingPublishNoteConsent": "我要公开此已保存的草稿。", - "mrPendingNotePublished": "评审草稿已发布。" + "mrPendingNotePublished": "评审草稿已发布。", + "mrReviewSummaryLabel": "公开评审摘要(可选)", + "mrReviewOutcomeLabel": "评审结果", + "mrReviewKeepState": "保持当前状态", + "mrReviewReviewed": "已评审", + "mrReviewRequestChanges": "请求更改", + "mrReviewCurrentState": "您当前的评审状态", + "mrReviewUnassigned": "未被指派为评审者", + "mrReviewUnreviewed": "尚未评审", + "mrReviewStarted": "已开始评审", + "mrReviewApproved": "已正式批准", + "mrReviewUnknownState": "无法识别的评审状态", + "mrReviewStateUnavailable": "无法读取评审状态。您仍可发布备注和公开摘要。", + "mrReviewReviewedHint": "已评审不会批准此合并请求,但可能清除您之前的更改请求。", + "mrReviewChangesHint": "请求更改可能阻止合并并撤销您已有的批准。", + "mrReviewPartialHint": "备注、摘要和评审状态可能已分别应用。决定是否再次提交前,请检查这三项。", + "mrReviewSubmissionConsent": "发布显示的待发备注和公开摘要,并应用所选的评审结果。", + "mrReviewUnapproved": "批准已撤回", + "mrReviewSubmitButton": "提交评审", + "mrReviewInspectButton": "检查评审" } diff --git a/apps/labfox/test/mr_draft_notes_publish_repository_test.dart b/apps/labfox/test/mr_draft_notes_publish_repository_test.dart index 67bb1c38..7609b322 100644 --- a/apps/labfox/test/mr_draft_notes_publish_repository_test.dart +++ b/apps/labfox/test/mr_draft_notes_publish_repository_test.dart @@ -6,6 +6,27 @@ import '../../../packages/gitlab_api/test/mr_draft_note_maintenance_api_test.dar show makeClient; void main() { + test('public review options stay on the captured client', () async { + final requests = []; + final c = makeClient((o) { + requests.add(o); + return (status: 204, body: null); + }); + addTearDown(c.close); + await MrDraftNotesRepository(c, authorId: 23).publish( + projectId: 8, + iid: 142, + mergeRequestId: 1100, + summaryNote: ' Summary\n ', + reviewerState: ReviewerSubmissionState.requestedChanges, + ); + expect(requests.single.data, { + 'note': ' Summary\n ', + 'internal': false, + 'reviewer_state': 'requested_changes', + }); + }); + test( 'captured client publishes the route IID with separate global MR identity', () async { diff --git a/apps/labfox/test/mr_pending_review_maintenance_widget_test.dart b/apps/labfox/test/mr_pending_review_maintenance_widget_test.dart index 1f29b4d7..def0f160 100644 --- a/apps/labfox/test/mr_pending_review_maintenance_widget_test.dart +++ b/apps/labfox/test/mr_pending_review_maintenance_widget_test.dart @@ -77,6 +77,13 @@ GitLabClient client() => GitLabClient( class Drafts extends MrDraftNotesRepository { Drafts(this.api) : super(api, authorId: 23); final GitLabClient api; + @override + Future> reviewers({ + required int projectId, + required int iid, + int page = 1, + int perPage = 20, + }) async => const Paginated(items: []); Object result = draft(7); final writes = <(int, int, int, String, DiffNotePosition?)>[]; final reads = []; diff --git a/apps/labfox/test/mr_pending_review_publish_test.dart b/apps/labfox/test/mr_pending_review_publish_test.dart index 64a67371..1f0d7987 100644 --- a/apps/labfox/test/mr_pending_review_publish_test.dart +++ b/apps/labfox/test/mr_pending_review_publish_test.dart @@ -18,6 +18,8 @@ class Drafts extends b.Drafts { required int projectId, required int iid, required int mergeRequestId, + String? summaryNote, + ReviewerSubmissionState? reviewerState, }) async { publications.add((projectId, iid, mergeRequestId)); if (publication is Future) return publication as Future; diff --git a/apps/labfox/test/mr_pending_review_publish_widget_test.dart b/apps/labfox/test/mr_pending_review_publish_widget_test.dart index b4858dda..25deb943 100644 --- a/apps/labfox/test/mr_pending_review_publish_widget_test.dart +++ b/apps/labfox/test/mr_pending_review_publish_widget_test.dart @@ -29,6 +29,8 @@ class Drafts extends b.Drafts { required int projectId, required int iid, required int mergeRequestId, + String? summaryNote, + ReviewerSubmissionState? reviewerState, }) async { expect((projectId, iid, mergeRequestId), (8, 142, 1100)); publications++; @@ -219,7 +221,7 @@ void main() { await open(t); expect(find.text('Second private note'), findsOneWidget); expect(find.text(l.mrPendingPublishHint), findsOneWidget); - expect(find.byType(TextField), findsNothing); + expect(find.byKey(const ValueKey('mr-review-summary')), findsOneWidget); expect(b.enabled(t, publishKey), false); expect(f.private.publications, 0); await b.tap(t, ackKey); diff --git a/apps/labfox/test/mr_review_submission_test.dart b/apps/labfox/test/mr_review_submission_test.dart new file mode 100644 index 00000000..8f699320 --- /dev/null +++ b/apps/labfox/test/mr_review_submission_test.dart @@ -0,0 +1,336 @@ +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gitlab_api/gitlab_api.dart'; +import 'package:gitlab_models/gitlab_models.dart'; +import 'package:labfox/features/merge_requests/presentation/controllers/mr_discussions_controller.dart'; +import 'mr_pending_review_publish_test.dart' as p; +import 'mr_pending_review_save_test.dart' as b; + +MergeRequestReviewer reviewer(String state, {int id = 23}) => + MergeRequestReviewer( + user: User( + id: id, + username: 'reviewer', + name: 'Reviewer', + state: 'active', + ), + state: state, + ); + +class Drafts extends p.Drafts { + Drafts(super.client); + final reviewerPages = { + 1: const Paginated(items: []), + }; + final reviewerReads = []; + final submissions = <(String?, ReviewerSubmissionState?)>[]; + String? appliedState; + void Function()? afterWrite; + void Function(int)? onRead; + @override + Future> reviewers({ + required int projectId, + required int iid, + int page = 1, + int perPage = 20, + }) async { + expect((projectId, iid), (8, 142)); + reviewerReads.add(page); + onRead?.call(reviewerReads.length); + final result = reviewerPages[page]!; + if (result is Future>) return result; + if (result is Paginated) return result; + throw result; + } + + @override + Future publish({ + required int projectId, + required int iid, + required int mergeRequestId, + String? summaryNote, + ReviewerSubmissionState? reviewerState, + }) async { + submissions.add((summaryNote, reviewerState)); + await super.publish( + projectId: projectId, + iid: iid, + mergeRequestId: mergeRequestId, + summaryNote: summaryNote, + reviewerState: reviewerState, + ); + afterWrite?.call(); + if (appliedState != null) { + reviewerPages[1] = Paginated(items: [reviewer(appliedState!)]); + } + } +} + +class Fixture extends b.Fixture { + late final private = Drafts(api); + Future initialize() async { + c.read(b.draftState.notifier).state = Future.value(private); + private.pages[1] = b.page([b.draft(7)]); + comments.pageResult = const Paginated(items: []); + await ready(); + } + + Future prepare() async => (await controller + .preparePendingReviewPublication(includeReviewerState: true))!; +} + +void main() { + for (final boundary in [2, 3]) { + for (final change in [ + 'client', + 'drafts', + 'details', + 'comments', + 'origin', + ]) { + test( + 'reviewer read boundary $boundary isolates obsolete $change', + () async { + final f = Fixture(); + await f.initialize(); + final s = await f.prepare(); + f.private.appliedState = 'reviewed'; + var current = true; + f.private.onRead = (count) { + if (count != boundary) return; + scheduleMicrotask(() { + switch (change) { + case 'client': + f.c.read(b.clientState.notifier).state = Future.value( + b.client(), + ); + case 'drafts': + f.c.read(b.draftState.notifier).state = Future.value( + Drafts(f.api), + ); + case 'details': + f.c.read(b.sourceState.notifier).state = Future.value( + b.Details(f.api), + ); + case 'comments': + f.c.read(b.commentsState.notifier).state = Future.value( + b.Comments(f.api), + ); + case 'origin': + current = false; + } + }); + }; + expect( + await f.controller.publishPendingReview( + s, + reviewerState: ReviewerSubmissionState.reviewed, + isCurrent: () => current, + ), + false, + ); + expect(f.private.submissions.length, boundary == 2 ? 0 : 1); + expect(f.controller.pendingPublicationNeedsInspection, boundary == 3); + }, + ); + } + } + test( + 'required reviewer inspection waits for actual cancelled write settlement', + () async { + final f = Fixture(); + await f.initialize(); + final s = await f.prepare(); + final write = Completer(); + f.private.publication = write.future; + final pending = f.controller.publishPendingReview( + s, + reviewerState: ReviewerSubmissionState.reviewed, + ); + while (f.private.submissions.isEmpty) { + await Future.delayed(Duration.zero); + } + f.c.read(b.clientState.notifier).state = Future.value(b.client()); + expect(await pending, false); + await f.ready(); + final before = f.private.reviewerReads.length; + final inspection = f.controller.inspectPendingReviewPublication(); + await Future.delayed(Duration.zero); + expect(f.private.reviewerReads.length, before); + expect(f.controller.pendingPublicationNeedsInspection, true); + write.complete(); + expect((await inspection)!.pending.reviewerStateAvailable, true); + expect(f.controller.pendingPublicationNeedsInspection, false); + expect(f.private.submissions.length, 1); + }, + ); + + test( + 'preparation reads every reviewer page and preserves separate account state', + () async { + final f = Fixture(); + await f.initialize(); + f.private.reviewerPages[1] = const Paginated( + items: [], + nextPage: 3, + ); + f.private.reviewerPages[3] = Paginated(items: [reviewer('unreviewed')]); + final s = await f.prepare(); + expect(s.reviewerStateAvailable, true); + expect(s.reviewer!.state, 'unreviewed'); + expect(s.reviewer!.user.state, 'active'); + expect(f.private.reviewerReads, [1, 3]); + }, + ); + for (final state in ReviewerSubmissionState.values) { + test('one exact public summary and $state with state readback', () async { + final f = Fixture(); + await f.initialize(); + final s = await f.prepare(); + f.private.appliedState = state.value; + expect( + await f.controller.publishPendingReview( + s, + summaryNote: ' **Summary**\n ', + reviewerState: state, + ), + true, + ); + expect(f.private.submissions, [(' **Summary**\n ', state)]); + expect(f.private.reviewerReads, [1, 1, 1]); + expect(f.controller.pendingPublicationNeedsInspection, false); + }); + test('state-only review can start with no private drafts $state', () async { + final f = Fixture(); + await f.initialize(); + f.private.pages[1] = b.page([]); + final s = await f.prepare(); + f.private.appliedState = state.value; + expect( + await f.controller.publishPendingReview(s, reviewerState: state), + true, + ); + expect(f.private.submissions, [(null, state)]); + }); + } + test( + 'summary-only review needs no private draft or reviewer endpoint', + () async { + final f = Fixture(); + await f.initialize(); + f.private.pages[1] = b.page([]); + f.private.reviewerPages[1] = const GitLabNotFoundException('Unavailable'); + final s = await f.prepare(); + expect(s.reviewerStateAvailable, false); + expect( + await f.controller.publishPendingReview(s, summaryNote: 'Summary'), + true, + ); + expect(f.private.submissions, [('Summary', null)]); + }, + ); + test('changed reviewer state blocks all publication before write', () async { + final f = Fixture(); + await f.initialize(); + final s = await f.prepare(); + f.private.reviewerPages[1] = Paginated(items: [reviewer('reviewed')]); + await expectLater( + f.controller.publishPendingReview( + s, + reviewerState: ReviewerSubmissionState.requestedChanges, + ), + throwsA(isA()), + ); + expect(f.private.submissions, isEmpty); + }); + test('reviewed cannot replace existing formal approval', () async { + final f = Fixture(); + await f.initialize(); + f.private.reviewerPages[1] = Paginated(items: [reviewer('approved')]); + final s = await f.prepare(); + await expectLater( + f.controller.publishPendingReview( + s, + reviewerState: ReviewerSubmissionState.reviewed, + ), + throwsA(isA()), + ); + expect(f.private.submissions, isEmpty); + }); + for (final result in [ + const Paginated(items: []), + const GitLabServerException('Readback failed'), + Paginated(items: [reviewer('unreviewed')]), + ]) { + test( + '204 without matching reviewer readback keeps shared recovery gate $result', + () async { + final f = Fixture(); + await f.initialize(); + final s = await f.prepare(); + f.private.appliedState = null; + // A successful mutation may leave status unchanged; response status cannot prove it. + if (result is GitLabException) { + f.private.afterWrite = () { + f.private.reviewerPages[1] = result; + }; + } else if (result is Paginated) { + f.private.afterWrite = () { + f.private.reviewerPages[1] = result; + }; + } + await expectLater( + f.controller.publishPendingReview( + s, + reviewerState: ReviewerSubmissionState.reviewed, + ), + throwsA(isA()), + ); + expect(f.controller.pendingPublicationNeedsInspection, true); + expect(f.private.submissions.length, 1); + f.private.reviewerPages[1] = const GitLabServerException( + 'Cannot inspect state', + ); + await expectLater( + f.controller.inspectPendingReviewPublication(), + throwsA(isA()), + ); + expect(f.controller.pendingPublicationNeedsInspection, true); + f.private.reviewerPages[1] = Paginated( + items: [reviewer('requested_changes')], + ); + final inspection = (await f.controller + .inspectPendingReviewPublication())!; + expect(inspection.pending.reviewerStateAvailable, true); + expect( + inspection.pending.forNote(7)!.reviewer!.state, + 'requested_changes', + ); + expect(f.controller.pendingPublicationNeedsInspection, false); + }, + ); + } + for (final page in [ + Paginated(items: [reviewer('reviewed'), reviewer('reviewed')]), + const Paginated(items: [], nextPage: 1), + ]) { + test( + 'invalid reviewer pagination prevents outcome authorization $page', + () async { + final f = Fixture(); + await f.initialize(); + f.private.reviewerPages[1] = page; + final s = await f.prepare(); + expect(s.reviewerStateAvailable, false); + expect( + await f.controller.publishPendingReview( + s, + reviewerState: ReviewerSubmissionState.reviewed, + ), + false, + ); + expect(f.private.submissions, isEmpty); + }, + ); + } +} diff --git a/apps/labfox/test/mr_review_submission_widget_test.dart b/apps/labfox/test/mr_review_submission_widget_test.dart new file mode 100644 index 00000000..b027d4e8 --- /dev/null +++ b/apps/labfox/test/mr_review_submission_widget_test.dart @@ -0,0 +1,382 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gitlab_api/gitlab_api.dart'; +import 'package:gitlab_models/gitlab_models.dart'; + +import 'mr_pending_review_maintenance_widget_test.dart' as b; +import 'mr_pending_review_publish_widget_test.dart' as p; + +const summaryKey = ValueKey('mr-review-summary'); +const stateKey = ValueKey('mr-review-state'); + +class Drafts extends p.Drafts { + Drafts(super.api); + Object review = const Paginated(items: []); + final submissions = <(String?, ReviewerSubmissionState?)>[]; + @override + Future> reviewers({ + required int projectId, + required int iid, + int page = 1, + int perPage = 20, + }) async { + if (review is Paginated) { + return review as Paginated; + } + throw review; + } + + @override + Future publish({ + required int projectId, + required int iid, + required int mergeRequestId, + String? summaryNote, + ReviewerSubmissionState? reviewerState, + }) async { + submissions.add((summaryNote, reviewerState)); + await super.publish( + projectId: projectId, + iid: iid, + mergeRequestId: mergeRequestId, + summaryNote: summaryNote, + reviewerState: reviewerState, + ); + if (reviewerState != null) { + review = Paginated( + items: [ + MergeRequestReviewer( + user: const User( + id: 23, + username: 'reviewer', + name: 'Reviewer', + state: 'active', + ), + state: reviewerState.value, + ), + ], + ); + } + } +} + +class Fixture extends b.Fixture { + late final private = Drafts(api); + void setup() { + c.read(b.draftState.notifier).state = Future.value(private); + } +} + +Future enter(WidgetTester t, String text) async { + await t.ensureVisible(find.byKey(summaryKey)); + await t.pump(); + await t.enterText(find.byKey(summaryKey), text); + await t.pumpAndSettle(); +} + +Future select(WidgetTester t, String label) async { + await b.tap(t, stateKey); + await t.tap(find.text(label).last); + await t.pumpAndSettle(); +} + +void main() { + for (final locale in ['en', 'ko', 'ja', 'hi', 'zh']) { + testWidgets('large text with keyboard supports full review in $locale', ( + t, + ) async { + final f = Fixture()..setup(); + final l = await b.pump( + t, + f, + width: 390, + height: 844, + scale: 1.8, + keyboard: 320, + locale: Locale(locale), + ); + await p.open(t); + await enter(t, 'Summary'); + await select(t, l.mrReviewRequestChanges); + final checkbox = find.descendant( + of: find.byKey(p.ackKey), + matching: find.byType(Checkbox), + ); + await t.ensureVisible(checkbox); + await t.pump(); + await t.tap(checkbox); + await t.pumpAndSettle(); + expect(b.enabled(t, p.publishKey), true); + expect(t.takeException(), isNull); + await b.tap(t, p.cancelKey); + }); + } + + if (Platform.environment['LABFOX_REVIEW_SUBMISSION_CAPTURE'] + case final String directory) { + for (final width in [390.0, 800.0, 1200.0]) { + for (final dark in [false, true]) { + for (final recovering in [false, true]) { + testWidgets('synthetic publication capture $width $dark $recovering', ( + t, + ) async { + final sdk = Platform.environment['FLUTTER_ROOT']!; + await t.runAsync(() async { + for (final (family, name) in [ + ('Roboto', 'Roboto-Regular.ttf'), + ('monospace', 'Roboto-Regular.ttf'), + ('MaterialIcons', 'MaterialIcons-Regular.otf'), + ]) { + final loader = FontLoader(family) + ..addFont( + Future.value( + ByteData.sublistView( + File( + '$sdk/bin/cache/artifacts/material_fonts/$name', + ).readAsBytesSync(), + ), + ), + ); + await loader.load(); + } + }); + final base = dark ? LabFoxTheme.dark : LabFoxTheme.light; + ButtonStyle font(ButtonStyle style) => style.copyWith( + textStyle: WidgetStatePropertyAll( + style.textStyle!.resolve({})!.copyWith(fontFamily: 'Roboto'), + ), + ); + final theme = base.copyWith( + textTheme: base.textTheme.apply(fontFamily: 'Roboto'), + outlinedButtonTheme: OutlinedButtonThemeData( + style: font(base.outlinedButtonTheme.style!), + ), + textButtonTheme: const TextButtonThemeData( + style: ButtonStyle( + textStyle: WidgetStatePropertyAll( + TextStyle(fontFamily: 'Roboto'), + ), + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: font(base.filledButtonTheme.style!), + ), + inputDecorationTheme: base.inputDecorationTheme.copyWith( + labelStyle: const TextStyle(fontFamily: 'Roboto'), + ), + ); + final f = Fixture()..setup(); + f.private.pages[1] = b.page([ + b.draft(7, note: 'Please cover empty intermediate pages.'), + b.draft( + 8, + note: 'The original Markdown stays private until publication.', + ), + ]); + final l = await b.pump( + t, + f, + width: width, + height: width < 600 ? 1000 : 1100, + dark: dark, + theme: theme, + ); + await p.open(t); + await enter( + t, + 'The pagination handling is ready after the noted changes.', + ); + await select(t, l.mrReviewRequestChanges); + if (recovering) { + await b.tap(t, p.ackKey); + f.private.outcome = const GitLabServerException( + 'Synthetic publication failure', + ); + await b.tap(t, p.publishKey); + f.comments.value = const Paginated( + items: [ + Discussion( + id: 'public-thread', + individualNote: true, + notes: [ + Note( + id: 99, + body: + 'Current public discussion. Check before another publication.', + ), + ], + ), + ], + ); + await b.tap(t, p.inspectKey); + } + // Capture with no focused-field cursor so repeated frames are stable. + FocusManager.instance.primaryFocus?.unfocus(); + await t.pumpAndSettle(); + expect(t.takeException(), isNull); + final boundary = t.renderObject( + find.byKey(b.captureKey), + ); + final image = (await t.runAsync(boundary.toImage))!; + final data = await t.runAsync( + () => image.toByteData(format: ui.ImageByteFormat.png), + ); + await t.runAsync(() async { + final file = File( + '$directory/${recovering ? 'inspection' : 'publish'}-${width.toInt()}-${dark ? 'dark' : 'light'}.png', + ); + await file.parent.create(recursive: true); + await file.writeAsBytes(data!.buffer.asUint8List()); + }); + image.dispose(); + }); + } + } + } + } + + testWidgets('exact summary and selected outcome require renewed consent', ( + t, + ) async { + final f = Fixture()..setup(); + final l = await b.pump(t, f); + await p.open(t); + expect(find.byKey(summaryKey), findsOneWidget); + expect(find.byKey(stateKey), findsOneWidget); + await b.tap(t, p.ackKey); + await enter(t, ' **Public summary**\n '); + expect(b.enabled(t, p.publishKey), false); + await b.tap(t, p.ackKey); + await select(t, l.mrReviewRequestChanges); + expect(b.enabled(t, p.publishKey), false); + await b.tap(t, p.ackKey); + await b.tap(t, p.publishKey); + expect(f.private.submissions, [ + (' **Public summary**\n ', ReviewerSubmissionState.requestedChanges), + ]); + expect(f.comments.posts, isEmpty); + expect(f.events.names, isEmpty); + }); + testWidgets( + 'unavailable preflight recovery clears outcome and permits summary publication', + (t) async { + final f = Fixture()..setup(); + final l = await b.pump(t, f); + await p.open(t); + await enter(t, 'Public summary'); + await select(t, l.mrReviewReviewed); + await b.tap(t, p.ackKey); + f.private.review = const GitLabNotFoundException('No status read'); + await b.tap(t, p.publishKey); + expect(f.private.submissions, isEmpty); + await b.tap(t, p.inspectKey); + expect(find.text(l.mrReviewStateUnavailable), findsOneWidget); + await b.tap(t, p.ackKey); + expect(b.enabled(t, p.publishKey), true); + await b.tap(t, p.publishKey); + expect(f.private.submissions, [('Public summary', null)]); + }, + ); + testWidgets('summary-only submission with empty drafts', (t) async { + final f = Fixture()..setup(); + f.private.pages[1] = b.page([]); + await b.pump(t, f); + await p.open(t); + expect(b.enabled(t, p.publishKey), false); + await enter(t, 'Public summary'); + await b.tap(t, p.ackKey); + await b.tap(t, p.publishKey); + expect(f.private.submissions, [('Public summary', null)]); + }); + testWidgets('unavailable reviewer read still permits notes and summary', ( + t, + ) async { + final f = Fixture()..setup(); + f.private.review = const GitLabNotFoundException('Unsupported'); + final l = await b.pump(t, f); + await p.open(t); + expect(find.text(l.mrReviewStateUnavailable), findsOneWidget); + expect( + t + .widget>( + find.byKey(stateKey), + ) + .onChanged, + isNull, + ); + await enter(t, 'Summary'); + await b.tap(t, p.ackKey); + await b.tap(t, p.publishKey); + expect(f.private.submissions, [('Summary', null)]); + }); + testWidgets( + 'uncertain submission retains input but requires inspection and fresh consent', + (t) async { + final f = Fixture()..setup(); + final l = await b.pump(t, f); + await p.open(t); + await enter(t, 'Retained summary'); + await select(t, l.mrReviewReviewed); + await b.tap(t, p.ackKey); + f.private.outcome = const GitLabServerException('Partly applied'); + await b.tap(t, p.publishKey); + expect(f.private.submissions.length, 1); + expect(find.text(l.mrReviewPartialHint), findsOneWidget); + expect( + t.widget(find.byKey(summaryKey)).controller!.text, + 'Retained summary', + ); + f.private.outcome = null; + await b.tap(t, p.inspectKey); + expect(b.enabled(t, p.publishKey), false); + expect(f.private.submissions.length, 1); + await b.tap(t, p.ackKey); + await b.tap(t, p.publishKey); + expect(f.private.submissions.length, 2); + }, + ); + testWidgets('account replacement discards unsent review options', (t) async { + final f = Fixture()..setup(); + final l = await b.pump(t, f); + await p.open(t); + await enter(t, 'Private input to discard'); + await select(t, l.mrReviewReviewed); + f.c.read(b.accountState.notifier).state = b.account.copyWith( + user: b.account.user.copyWith(id: 99), + ); + await t.pumpAndSettle(); + expect(find.byKey(summaryKey), findsNothing); + expect(find.text('Private input to discard'), findsNothing); + expect(b.enabled(t, p.publishKey), false); + }); + for (final locale in ['en', 'ko', 'ja', 'hi', 'zh']) { + for (final width in [390.0, 800.0, 1200.0]) { + for (final dark in [false, true]) { + testWidgets('review options $locale $width dark=$dark', (t) async { + final f = Fixture()..setup(); + final l = await b.pump( + t, + f, + width: width, + dark: dark, + locale: Locale(locale), + ); + await p.open(t); + await enter(t, 'Synthetic public summary'); + await select(t, l.mrReviewRequestChanges); + await b.tap(t, p.ackKey); + expect(b.enabled(t, p.publishKey), true); + expect(t.takeException(), isNull); + await b.tap(t, p.cancelKey); + expect(f.private.submissions, isEmpty); + }); + } + } + } +} diff --git a/docs/images/mr-review-submission/inspection-1200-dark.png b/docs/images/mr-review-submission/inspection-1200-dark.png new file mode 100644 index 00000000..3f86872a Binary files /dev/null and b/docs/images/mr-review-submission/inspection-1200-dark.png differ diff --git a/docs/images/mr-review-submission/inspection-1200-light.png b/docs/images/mr-review-submission/inspection-1200-light.png new file mode 100644 index 00000000..4c931a34 Binary files /dev/null and b/docs/images/mr-review-submission/inspection-1200-light.png differ diff --git a/docs/images/mr-review-submission/inspection-390-dark.png b/docs/images/mr-review-submission/inspection-390-dark.png new file mode 100644 index 00000000..c8fa0cd5 Binary files /dev/null and b/docs/images/mr-review-submission/inspection-390-dark.png differ diff --git a/docs/images/mr-review-submission/inspection-390-light.png b/docs/images/mr-review-submission/inspection-390-light.png new file mode 100644 index 00000000..8b918921 Binary files /dev/null and b/docs/images/mr-review-submission/inspection-390-light.png differ diff --git a/docs/images/mr-review-submission/inspection-800-dark.png b/docs/images/mr-review-submission/inspection-800-dark.png new file mode 100644 index 00000000..7d9ff215 Binary files /dev/null and b/docs/images/mr-review-submission/inspection-800-dark.png differ diff --git a/docs/images/mr-review-submission/inspection-800-light.png b/docs/images/mr-review-submission/inspection-800-light.png new file mode 100644 index 00000000..59843b36 Binary files /dev/null and b/docs/images/mr-review-submission/inspection-800-light.png differ diff --git a/docs/images/mr-review-submission/publish-1200-dark.png b/docs/images/mr-review-submission/publish-1200-dark.png new file mode 100644 index 00000000..14d84aee Binary files /dev/null and b/docs/images/mr-review-submission/publish-1200-dark.png differ diff --git a/docs/images/mr-review-submission/publish-1200-light.png b/docs/images/mr-review-submission/publish-1200-light.png new file mode 100644 index 00000000..2743b131 Binary files /dev/null and b/docs/images/mr-review-submission/publish-1200-light.png differ diff --git a/docs/images/mr-review-submission/publish-390-dark.png b/docs/images/mr-review-submission/publish-390-dark.png new file mode 100644 index 00000000..c86639f8 Binary files /dev/null and b/docs/images/mr-review-submission/publish-390-dark.png differ diff --git a/docs/images/mr-review-submission/publish-390-light.png b/docs/images/mr-review-submission/publish-390-light.png new file mode 100644 index 00000000..552a4c35 Binary files /dev/null and b/docs/images/mr-review-submission/publish-390-light.png differ diff --git a/docs/images/mr-review-submission/publish-800-dark.png b/docs/images/mr-review-submission/publish-800-dark.png new file mode 100644 index 00000000..55adca62 Binary files /dev/null and b/docs/images/mr-review-submission/publish-800-dark.png differ diff --git a/docs/images/mr-review-submission/publish-800-light.png b/docs/images/mr-review-submission/publish-800-light.png new file mode 100644 index 00000000..bdc64e88 Binary files /dev/null and b/docs/images/mr-review-submission/publish-800-light.png differ diff --git a/docs/mobile-web-parity.md b/docs/mobile-web-parity.md index 268a4048..596cfaca 100644 --- a/docs/mobile-web-parity.md +++ b/docs/mobile-web-parity.md @@ -57,7 +57,7 @@ issues and PRs. Keep it open until its full acceptance boundary is verified. | MW-04 | P1 | Member invitations, role/expiry changes, and removal for groups/projects. | Queued | Issue needed | | MW-05 | P0 | Issue/work-item editing, metadata, types, and validated state transitions. | In progress | [#329](https://github.com/Theorvane/labfox/issues/329), [PR #330](https://github.com/Theorvane/labfox/pull/330) shipped title and description editing; [#339](https://github.com/Theorvane/labfox/issues/339), [PR #340](https://github.com/Theorvane/labfox/pull/340) shipped due-date editing; [#341](https://github.com/Theorvane/labfox/issues/341), [PR #342](https://github.com/Theorvane/labfox/pull/342) shipped issue-label editing; [#343](https://github.com/Theorvane/labfox/issues/343), [PR #344](https://github.com/Theorvane/labfox/pull/344) shipped assignee editing; [#345](https://github.com/Theorvane/labfox/issues/345), [PR #346](https://github.com/Theorvane/labfox/pull/346) shipped milestone editing; [#347](https://github.com/Theorvane/labfox/issues/347), [PR #348](https://github.com/Theorvane/labfox/pull/348) shipped confidentiality; [#349](https://github.com/Theorvane/labfox/issues/349), [PR #350](https://github.com/Theorvane/labfox/pull/350) shipped discussion locking. Type-conversion compatibility and remaining work-item actions need separate issues. | | MW-06 | P1 | Boards and iterations: discover mobile-web behavior, then list/detail/mutations. | Queued | Issue needed | -| MW-07 | P0 | Advanced MR review: audit inline discussions/suggestions and finish missing review/approval flows. | In progress | [#552](https://github.com/Theorvane/labfox/issues/552), [PR #553](https://github.com/Theorvane/labfox/pull/553) prevent overlapping approval, merge, and other MR commands per project/IID, including while the repository resolves; failures release the reservation for explicit retry. [#554](https://github.com/Theorvane/labfox/issues/554), [PR #555](https://github.com/Theorvane/labfox/pull/555) add repository-session observation, pre-dispatch cancellation and late-result/error isolation, success-only current-session refresh/analytics/inbox effects, cancelled to-do outcomes without current-account toasts, and generation-owned duplicate reservations. Already dispatched server writes cannot be undone. [#556](https://github.com/Theorvane/labfox/issues/556), [PR #557](https://github.com/Theorvane/labfox/pull/557) add localized approval-read loading/error states, disabled approval toggles until successful reads, read-only retry, retained-data refresh protection, reactive new-session reads with stale-result isolation, and current-state toggle selection while preserving unavailable endpoint compatibility and independent merge controls. [#558](https://github.com/Theorvane/labfox/issues/558), [PR #559](https://github.com/Theorvane/labfox/pull/559) derives approval ownership from the authenticated account user ID and documented `approved_by` membership, ignores conflicting legacy current-user flags, and refreshes membership on account replacement. Regression coverage checks real toggle dispatch on mobile/desktop in both themes. [#560](https://github.com/Theorvane/labfox/issues/560), [PR #561](https://github.com/Theorvane/labfox/pull/561) validates successful approval responses before model parsing: explicit approver lists, complete user wrappers, positive integral user IDs, and nonnegative integral counts when present. Malformed reads become sanitized domain errors; status mapping precedes payload parsing. End-to-end widget tests verify blocked approval toggles and read-only recovery in both widths/themes, while unavailable endpoint compatibility remains intact. [#562](https://github.com/Theorvane/labfox/issues/562), [PR #563](https://github.com/Theorvane/labfox/pull/563) adds an account-bound, one-page MR discussion reader with generated grouped-note models and nullable resolution metadata. It preserves next-page headers without assuming totals or fetching ahead, rejects incomplete groups and nonadvancing cursors, and keeps HTTP failures distinct from sanitized parsing errors. Building on the API/models foundation, [#564](https://github.com/Theorvane/labfox/issues/564), [PR #565](https://github.com/Theorvane/labfox/pull/565) connects grouped MR replies to the detail-screen comments flow and aggregates resolution across all resolvable notes without claiming unknown states are resolved. The shared top-level composer refreshes discussion page one after posting; explicit load-more and read/page retries preserve groups and cursors. Account-bound reads/posts ignore late results, drafts reset when the session or resource changes, and resizing preserves the current draft. Five locales and narrow/compact/wide light/dark layouts are covered. [#566](https://github.com/Theorvane/labfox/issues/566), [PR #567](https://github.com/Theorvane/labfox/pull/567) adds the MR discussion reply API foundation: encoded thread/project paths, explicit IID routing, exact nonempty Markdown bodies, validated generated note responses, sanitized typed errors and no automatic authentication replay for the write. [#568](https://github.com/Theorvane/labfox/issues/568), [PR #569](https://github.com/Theorvane/labfox/pull/569) connects a localized inline reply composer to the selected discussion. One active reply preserves the top-level draft; cancel and write reservations prevent accidental overlaps. Replies preserve Markdown, retain failed drafts for explicit retry, refresh authoritative discussion page one and ignore old-session completions. Account/MR replacement resets the selection, resizing and pagination preserve current drafts, and a removed target restores top-level submission. Five locales and three widths in both themes are covered. [#570](https://github.com/Theorvane/labfox/issues/570), [PR #571](https://github.com/Theorvane/labfox/pull/571) adds known-state Resolve/Reopen controls and a strict account-bound mutation: encoded identity, explicit resolution, no redirects/authentication replay, and updated thread/state confirmation. Resolution shares comment/reply write reservations, preserves the top-level draft, retains localized permission/failure feedback for explicit retry, and refreshes discussions and MR detail only on current-session success. Unknown/nonresolvable states offer no toggle, and an active reply draft prevents resolution. [#572](https://github.com/Theorvane/labfox/issues/572), [PR #573](https://github.com/Theorvane/labfox/pull/573) preserves generated diff-note position metadata: original version SHAs, renamed paths, old/new text lines, multiline range endpoints and image/file types. Discussion reads, replies and resolution responses reject malformed or fractional coordinates before parsing; absent legacy fields remain unknown. [#574](https://github.com/Theorvane/labfox/issues/574), [PR #575](https://github.com/Theorvane/labfox/pull/575) adds paginated diff-version reads and an explicitly selected original unified-diff snapshot. Generated version/file models retain SHA triplets, renamed paths, raw text and nullable omission flags; absent files differ from empty snapshots. Identity/payload validation, status-first typed failures and no eager pagination or current-diff fallback are covered. [#576](https://github.com/Theorvane/labfox/issues/576) / [PR #577](https://github.com/Theorvane/labfox/pull/577) adds an expandable original-diff context beside positioned notes. It matches complete SHA triplets, both file paths and exact old/new single-line text coordinates; explicit older-page reads and read-only retries preserve drafts. The matched original hunk/line has an accessible non-color marker. Unsupported, omitted, truncated or ambiguous contexts remain unavailable without a current-diff fallback; account/resource replacement resets panels and stale reads cannot dispatch follow-up snapshots or affect the new session. Five locales, three widths and both themes are covered. Multiline context, positioned thread creation and suggestion application remain separate slices. [#578](https://github.com/Theorvane/labfox/issues/578), [PR #580](https://github.com/Theorvane/labfox/pull/580) adds the single-line positioned discussion creation API foundation: exact original SHA/path/line anchors, Markdown preservation, pre-dispatch argument validation, no redirects or authentication replay, created-thread confirmation and sanitized typed failures. The line selector/composer, multiline creation and suggestions remain separate. [#579](https://github.com/Theorvane/labfox/issues/579), [PR #581](https://github.com/Theorvane/labfox/pull/581) connects the latest authoritative diff-version snapshot to localized single-line selection and discussion creation. Exact original coordinates, indexed eligibility, shared write reservations, retained drafts, session/resource isolation and visible authoritative discussion inspection before uncertain-write retry are covered. The existing MR Changes route restores from project/IID; commit diffs stay read-only. Multiline context/creation and suggestion application remain separate. [#582](https://github.com/Theorvane/labfox/issues/582) / [PR #583](https://github.com/Theorvane/labfox/pull/583) adds typed immutable suggestion metadata to MR discussion responses, preserving original/replacement code, inclusive ranges, applied status and both documented applicability spellings. Missing states remain unknown; positive integral identities/coordinates, range order, response-wide uniqueness, content/flag types and alias agreement are validated before generated parsing. Suggestion presentation/application and multiline context/creation remain separate. [#584](https://github.com/Theorvane/labfox/issues/584) / [PR #585](https://github.com/Theorvane/labfox/pull/585) adds read-only suggestion previews beside user notes with exact selectable original/replacement code, nullable application/applicability states, localized ranges and empty/unavailable content, bounded code panes, retained drafts across resize/theme, and account/resource/metadata isolation. Suggestion application and batching remain separate. [#586](https://github.com/Theorvane/labfox/issues/586) / [PR #587](https://github.com/Theorvane/labfox/pull/587) adds the single-suggestion application API foundation with a positive global identity, exact optional commit messages, strict HTTP 200 and matching applied=true confirmation, shared metadata validation, typed sanitized failures, and no redirect/authentication replay. Application UI/session reservations and batch application remain separate. [#588](https://github.com/Theorvane/labfox/issues/588) / [PR #589](https://github.com/Theorvane/labfox/pull/589) adds explicit single-suggestion confirmation with literal code and optional commit messages, a fresh exact-discussion preflight, shared reservations including pagination, current-view/account isolation, confirmed-success detail/discussion/review-snapshot refreshes, and draft-preserving read-only recovery before uncertain-write retry. Five locales, three widths, both themes, keyboard insets and large text are covered. Batch application and multiline context/creation remain separate. [#590](https://github.com/Theorvane/labfox/issues/590) / [PR #591](https://github.com/Theorvane/labfox/pull/591) adds the batch suggestion API foundation: one account-bound request with immutable positive unique global IDs, exact optional messages, strict complete matching applied-state confirmation, preserved server order/nullable metadata, status-first sanitized failures, and no redirects, authentication replay or single-write fallback. Building on that API, [#592](https://github.com/Theorvane/labfox/issues/592) / [PR #593](https://github.com/Theorvane/labfox/pull/593) adds explicit loaded-target selection and multi-patch confirmation, deduplicated all-thread preflight before one batch, immutable selected identities, shared reservations, current-account/view isolation, complete staged read-only recovery, exact retained drafts and success-only refreshes. Five locales, three widths, both themes and compact keyboard/large-text layouts are covered; see [the batch UI](mr-suggestion-batch-ui.md). [#594](https://github.com/Theorvane/labfox/issues/594) / [PR #595](https://github.com/Theorvane/labfox/pull/595) adds exact original multiline context with inclusive side-aware per-line markers, full filename-hash/raw-counter endpoint identity, optional coordinate support, complete contiguous hunk checks, duplicate/reversed/gapped-span rejection and retained draft/session behavior. Five locales, three widths, both themes and compact keyboard/large-text layouts are covered; see [original context](mr-discussion-context.md). [#596](https://github.com/Theorvane/labfox/issues/596) / [PR #597](https://github.com/Theorvane/labfox/pull/597) adds explicit original multiline selection and positioned creation, indexed complete contiguous range eligibility, exact endpoint request/response confirmation, inclusive accessible markers, retained drafts and complete-range recovery inspection. Repository identity is checked before dispatch and after completion to isolate replaced-session outcomes. Five locales, three widths, both themes and compact keyboard/large-text layouts are covered; see [multiline creation](mr-multiline-discussions.md). [#598](https://github.com/Theorvane/labfox/issues/598) / [PR #599](https://github.com/Theorvane/labfox/pull/599) adds the read-only pending-review draft foundation: generated private note/global MR/author identities, exact Markdown and nullable original position/reply/resolution metadata; strict one-page draft_notes reads with immutable server order, header pagination, sanitized malformed/status failures and normal read-only OAuth refresh. Account-bound repositories reject other-author pages; auto-disposed page consumers prevent stale pre-dispatch reads and ignore late outcomes. Presentation removes retained private rows immediately during account/client/instance changes, sign-out, loading and failed refresh. Draft mutations, aggregation and pending-review UI remain separate; no live GitLab/device validation is claimed. [#600](https://github.com/Theorvane/labfox/issues/600) / [PR #601](https://github.com/Theorvane/labfox/pull/601) adds the unpublished regular/original text-range draft creation API prerequisite: exact Markdown and recursive null omission, strict HTTP 201 and original-position/body confirmation, no redirect/OAuth replay/automatic retry/public-comment fallback, and sanitized typed/status-preserving failures. Account-bound repositories require and confirm captured author plus explicit global MR identity separately from route IID. Shared text/range validation preserves published discussion behavior. Replies, commit/image/file drafts, editing/deletion, publication and the pending-review controller/UI remain follow-up work; uncertain writes require authoritative inspection before an explicit retry, and no live GitLab/device validation is claimed. [#602](https://github.com/Theorvane/labfox/issues/602) / [PR #603](https://github.com/Theorvane/labfox/pull/603) adds read-only pending-review pagination with an authoritative global MR identity separate from route IID, immutable server order, explicit cursors through empty pages, optional totals and duplicate-load prevention. Mismatched author/MR identities, duplicate drafts and backward cursors reject the full result. Account/repository generations cancel obsolete dispatch and isolate initial/pagination success/errors. Presentation immediately removes retained private rows during account/client/instance/sign-out, refresh and failed reads; explicit retry restarts page one. Offset traversal is not an atomic snapshot and cannot authorize publication. The controller exposes no UI or mutation; see [private pagination](mr-pending-review-reader.md). [#604](https://github.com/Theorvane/labfox/issues/604) / [PR #605](https://github.com/Theorvane/labfox/pull/605) adds a read-only private pending-review panel on MR detail with exact selectable saved Markdown, known original metadata, localized loading/empty/partial/error states and explicit paging/refresh/retry. Guarded queries await fresh MR detail and verify route/global identity before dispatch, closing account/instance/client replacement requests ahead of detail; old private rows disappear during detail refresh and failed reads. Five locales, three widths, both themes, large text, long content and resize/theme preservation are covered with six synthetic captures. The panel sends no mutation or embedded image request and never infers/re-anchors original positions; see [private review panel](mr-pending-review-panel.md). Creation orchestration, uncertain-write recovery and publication remain follow-ups; no live GitLab/device validation is claimed. [#606](https://github.com/Theorvane/labfox/issues/606) / [PR #607](https://github.com/Theorvane/LabFox/pull/607) adds regular private review save orchestration with fresh account-bound detail/global identity, shared discussion write/pagination reservations, exact Markdown and one confirmed create before route-scoped private-reader refresh. Uncertain writes block replay across ordinary refreshes and same-account client/repository replacement; explicit complete read-only inspection follows empty-page cursors, validates ownership/identity/uniqueness/order and waits for any dispatched write to settle. Old-session/view results and errors cannot expose private rows or dispatch further pages. The controller has no composer UI, positioned/reply save orchestration, editing/deletion or publication; see [guarded saves](mr-pending-review-save.md). Recovery is in-memory and offset inspection cannot promise exactly-once creation after a client timeout; no live-instance/device validation is claimed. [#608](https://github.com/Theorvane/LabFox/issues/608) / [PR #609](https://github.com/Theorvane/LabFox/pull/609) adds a localized regular private-note composer on MR detail with exact Markdown, guarded one-save orchestration, retained failed input, complete visible inspection and separate acknowledgement before manual retry. Account/client/repository/resource replacement discards private input and snapshots; obsolete results cannot toast or expose data. Public drafts stay intact, and same-account reopen retains uncertain-write gating. Five locales, three widths, both themes and keyboard/large-text recovery are covered with twelve synthetic captures; see [private composer](mr-pending-review-composer.md). Positioned/reply/commit saves, editing/deletion and publication remain separate; no exactly-once or live-instance/device validation is claimed. [#610](https://github.com/Theorvane/LabFox/issues/610) / [PR #611](https://github.com/Theorvane/LabFox/pull/611) adds private draft update/delete API and account-bound repository foundations: exact Markdown, explicit original text-position preservation, strict 200 target/body/position confirmation and 204 deletion acknowledgement, positive project/IID/draft identities, captured author/global-MR checks and unchanged non-body metadata. Status-first plain-wire decoding preserves typed failures even for malformed JSON; redirects, authentication replay, fallback writes and automatic retry are disabled. Original line-code-only and unsupported update anchors fail before dispatch; owned opaque/image/file drafts can be deleted by identity. See [maintenance contract](mr-draft-note-maintenance.md). Fresh target/controller reservations, localized edit/delete confirmation/recovery and publication remain follow-ups; no live-instance/device validation is claimed. [#612](https://github.com/Theorvane/LabFox/issues/612) connects private draft editing and confirmed deletion to the existing pending review panel. Shared discussion reservations, fresh detail/global identity and complete private-page comparison refuse stale or missing selected targets before one write. Uncertain results gate every private write; complete visible inspection and acknowledgement adopt a current target only for explicit retry, with obsolete session/view input and outcomes discarded. Five locales, keyboard/large text and twelve synthetic captures cover three widths and both themes; see [maintenance UI](mr-pending-review-maintenance.md). Publication and positioned/reply/commit creation remain separate; no atomic snapshot, conditional-write, durable recovery or live-instance/device guarantee is claimed. [#614](https://github.com/Theorvane/LabFox/issues/614) adds whole-review publication: complete visible private-note confirmation and consent, a fresh full-set comparison before one strict-204 account-scoped bulk POST, and uncertainty that blocks private mutations until complete private/public inspection. Manual retries require new consent; session/view changes discard old confirmation and outcomes. Five locales and three widths in both themes are covered; see [publication flow](mr-pending-review-publication.md). Single-note publication, summary/reviewer-state controls and positioned/reply/commit creation remain separate. No automatic approval/merge, atomic server snapshot, durable recovery or live-instance/device guarantee is claimed. [#616](https://github.com/Theorvane/labfox/issues/616) adds private text line/range draft creation from the MR changes screen, fresh authoritative diff selection checks, immutable selected text, session/snapshot/origin isolation, preserved public input and explicit uncertain-save inspection. See [private inline review](mr-pending-review-inline.md). [#618](https://github.com/Theorvane/LabFox/issues/618) adds selected saved-note publication through one strict-204 PUT, fresh exact target comparison, explicit consent and shared two-sided recovery. Unrelated saved notes remain private; a missing selected ID cannot publish another note. See [single-note publication](mr-pending-review-single-publication.md). Summary/reviewer-state controls and additional draft creation types remain separate; no atomic, durable recovery or live-instance/device guarantee is claimed. | +| MW-07 | P0 | Advanced MR review: audit inline discussions/suggestions and finish missing review/approval flows. | In progress | [#552](https://github.com/Theorvane/labfox/issues/552), [PR #553](https://github.com/Theorvane/labfox/pull/553) prevent overlapping approval, merge, and other MR commands per project/IID, including while the repository resolves; failures release the reservation for explicit retry. [#554](https://github.com/Theorvane/labfox/issues/554), [PR #555](https://github.com/Theorvane/labfox/pull/555) add repository-session observation, pre-dispatch cancellation and late-result/error isolation, success-only current-session refresh/analytics/inbox effects, cancelled to-do outcomes without current-account toasts, and generation-owned duplicate reservations. Already dispatched server writes cannot be undone. [#556](https://github.com/Theorvane/labfox/issues/556), [PR #557](https://github.com/Theorvane/labfox/pull/557) add localized approval-read loading/error states, disabled approval toggles until successful reads, read-only retry, retained-data refresh protection, reactive new-session reads with stale-result isolation, and current-state toggle selection while preserving unavailable endpoint compatibility and independent merge controls. [#558](https://github.com/Theorvane/labfox/issues/558), [PR #559](https://github.com/Theorvane/labfox/pull/559) derives approval ownership from the authenticated account user ID and documented `approved_by` membership, ignores conflicting legacy current-user flags, and refreshes membership on account replacement. Regression coverage checks real toggle dispatch on mobile/desktop in both themes. [#560](https://github.com/Theorvane/labfox/issues/560), [PR #561](https://github.com/Theorvane/labfox/pull/561) validates successful approval responses before model parsing: explicit approver lists, complete user wrappers, positive integral user IDs, and nonnegative integral counts when present. Malformed reads become sanitized domain errors; status mapping precedes payload parsing. End-to-end widget tests verify blocked approval toggles and read-only recovery in both widths/themes, while unavailable endpoint compatibility remains intact. [#562](https://github.com/Theorvane/labfox/issues/562), [PR #563](https://github.com/Theorvane/labfox/pull/563) adds an account-bound, one-page MR discussion reader with generated grouped-note models and nullable resolution metadata. It preserves next-page headers without assuming totals or fetching ahead, rejects incomplete groups and nonadvancing cursors, and keeps HTTP failures distinct from sanitized parsing errors. Building on the API/models foundation, [#564](https://github.com/Theorvane/labfox/issues/564), [PR #565](https://github.com/Theorvane/labfox/pull/565) connects grouped MR replies to the detail-screen comments flow and aggregates resolution across all resolvable notes without claiming unknown states are resolved. The shared top-level composer refreshes discussion page one after posting; explicit load-more and read/page retries preserve groups and cursors. Account-bound reads/posts ignore late results, drafts reset when the session or resource changes, and resizing preserves the current draft. Five locales and narrow/compact/wide light/dark layouts are covered. [#566](https://github.com/Theorvane/labfox/issues/566), [PR #567](https://github.com/Theorvane/labfox/pull/567) adds the MR discussion reply API foundation: encoded thread/project paths, explicit IID routing, exact nonempty Markdown bodies, validated generated note responses, sanitized typed errors and no automatic authentication replay for the write. [#568](https://github.com/Theorvane/labfox/issues/568), [PR #569](https://github.com/Theorvane/labfox/pull/569) connects a localized inline reply composer to the selected discussion. One active reply preserves the top-level draft; cancel and write reservations prevent accidental overlaps. Replies preserve Markdown, retain failed drafts for explicit retry, refresh authoritative discussion page one and ignore old-session completions. Account/MR replacement resets the selection, resizing and pagination preserve current drafts, and a removed target restores top-level submission. Five locales and three widths in both themes are covered. [#570](https://github.com/Theorvane/labfox/issues/570), [PR #571](https://github.com/Theorvane/labfox/pull/571) adds known-state Resolve/Reopen controls and a strict account-bound mutation: encoded identity, explicit resolution, no redirects/authentication replay, and updated thread/state confirmation. Resolution shares comment/reply write reservations, preserves the top-level draft, retains localized permission/failure feedback for explicit retry, and refreshes discussions and MR detail only on current-session success. Unknown/nonresolvable states offer no toggle, and an active reply draft prevents resolution. [#572](https://github.com/Theorvane/labfox/issues/572), [PR #573](https://github.com/Theorvane/labfox/pull/573) preserves generated diff-note position metadata: original version SHAs, renamed paths, old/new text lines, multiline range endpoints and image/file types. Discussion reads, replies and resolution responses reject malformed or fractional coordinates before parsing; absent legacy fields remain unknown. [#574](https://github.com/Theorvane/labfox/issues/574), [PR #575](https://github.com/Theorvane/labfox/pull/575) adds paginated diff-version reads and an explicitly selected original unified-diff snapshot. Generated version/file models retain SHA triplets, renamed paths, raw text and nullable omission flags; absent files differ from empty snapshots. Identity/payload validation, status-first typed failures and no eager pagination or current-diff fallback are covered. [#576](https://github.com/Theorvane/labfox/issues/576) / [PR #577](https://github.com/Theorvane/labfox/pull/577) adds an expandable original-diff context beside positioned notes. It matches complete SHA triplets, both file paths and exact old/new single-line text coordinates; explicit older-page reads and read-only retries preserve drafts. The matched original hunk/line has an accessible non-color marker. Unsupported, omitted, truncated or ambiguous contexts remain unavailable without a current-diff fallback; account/resource replacement resets panels and stale reads cannot dispatch follow-up snapshots or affect the new session. Five locales, three widths and both themes are covered. Multiline context, positioned thread creation and suggestion application remain separate slices. [#578](https://github.com/Theorvane/labfox/issues/578), [PR #580](https://github.com/Theorvane/labfox/pull/580) adds the single-line positioned discussion creation API foundation: exact original SHA/path/line anchors, Markdown preservation, pre-dispatch argument validation, no redirects or authentication replay, created-thread confirmation and sanitized typed failures. The line selector/composer, multiline creation and suggestions remain separate. [#579](https://github.com/Theorvane/labfox/issues/579), [PR #581](https://github.com/Theorvane/labfox/pull/581) connects the latest authoritative diff-version snapshot to localized single-line selection and discussion creation. Exact original coordinates, indexed eligibility, shared write reservations, retained drafts, session/resource isolation and visible authoritative discussion inspection before uncertain-write retry are covered. The existing MR Changes route restores from project/IID; commit diffs stay read-only. Multiline context/creation and suggestion application remain separate. [#582](https://github.com/Theorvane/labfox/issues/582) / [PR #583](https://github.com/Theorvane/labfox/pull/583) adds typed immutable suggestion metadata to MR discussion responses, preserving original/replacement code, inclusive ranges, applied status and both documented applicability spellings. Missing states remain unknown; positive integral identities/coordinates, range order, response-wide uniqueness, content/flag types and alias agreement are validated before generated parsing. Suggestion presentation/application and multiline context/creation remain separate. [#584](https://github.com/Theorvane/labfox/issues/584) / [PR #585](https://github.com/Theorvane/labfox/pull/585) adds read-only suggestion previews beside user notes with exact selectable original/replacement code, nullable application/applicability states, localized ranges and empty/unavailable content, bounded code panes, retained drafts across resize/theme, and account/resource/metadata isolation. Suggestion application and batching remain separate. [#586](https://github.com/Theorvane/labfox/issues/586) / [PR #587](https://github.com/Theorvane/labfox/pull/587) adds the single-suggestion application API foundation with a positive global identity, exact optional commit messages, strict HTTP 200 and matching applied=true confirmation, shared metadata validation, typed sanitized failures, and no redirect/authentication replay. Application UI/session reservations and batch application remain separate. [#588](https://github.com/Theorvane/labfox/issues/588) / [PR #589](https://github.com/Theorvane/labfox/pull/589) adds explicit single-suggestion confirmation with literal code and optional commit messages, a fresh exact-discussion preflight, shared reservations including pagination, current-view/account isolation, confirmed-success detail/discussion/review-snapshot refreshes, and draft-preserving read-only recovery before uncertain-write retry. Five locales, three widths, both themes, keyboard insets and large text are covered. Batch application and multiline context/creation remain separate. [#590](https://github.com/Theorvane/labfox/issues/590) / [PR #591](https://github.com/Theorvane/labfox/pull/591) adds the batch suggestion API foundation: one account-bound request with immutable positive unique global IDs, exact optional messages, strict complete matching applied-state confirmation, preserved server order/nullable metadata, status-first sanitized failures, and no redirects, authentication replay or single-write fallback. Building on that API, [#592](https://github.com/Theorvane/labfox/issues/592) / [PR #593](https://github.com/Theorvane/labfox/pull/593) adds explicit loaded-target selection and multi-patch confirmation, deduplicated all-thread preflight before one batch, immutable selected identities, shared reservations, current-account/view isolation, complete staged read-only recovery, exact retained drafts and success-only refreshes. Five locales, three widths, both themes and compact keyboard/large-text layouts are covered; see [the batch UI](mr-suggestion-batch-ui.md). [#594](https://github.com/Theorvane/labfox/issues/594) / [PR #595](https://github.com/Theorvane/labfox/pull/595) adds exact original multiline context with inclusive side-aware per-line markers, full filename-hash/raw-counter endpoint identity, optional coordinate support, complete contiguous hunk checks, duplicate/reversed/gapped-span rejection and retained draft/session behavior. Five locales, three widths, both themes and compact keyboard/large-text layouts are covered; see [original context](mr-discussion-context.md). [#596](https://github.com/Theorvane/labfox/issues/596) / [PR #597](https://github.com/Theorvane/labfox/pull/597) adds explicit original multiline selection and positioned creation, indexed complete contiguous range eligibility, exact endpoint request/response confirmation, inclusive accessible markers, retained drafts and complete-range recovery inspection. Repository identity is checked before dispatch and after completion to isolate replaced-session outcomes. Five locales, three widths, both themes and compact keyboard/large-text layouts are covered; see [multiline creation](mr-multiline-discussions.md). [#598](https://github.com/Theorvane/labfox/issues/598) / [PR #599](https://github.com/Theorvane/labfox/pull/599) adds the read-only pending-review draft foundation: generated private note/global MR/author identities, exact Markdown and nullable original position/reply/resolution metadata; strict one-page draft_notes reads with immutable server order, header pagination, sanitized malformed/status failures and normal read-only OAuth refresh. Account-bound repositories reject other-author pages; auto-disposed page consumers prevent stale pre-dispatch reads and ignore late outcomes. Presentation removes retained private rows immediately during account/client/instance changes, sign-out, loading and failed refresh. Draft mutations, aggregation and pending-review UI remain separate; no live GitLab/device validation is claimed. [#600](https://github.com/Theorvane/labfox/issues/600) / [PR #601](https://github.com/Theorvane/labfox/pull/601) adds the unpublished regular/original text-range draft creation API prerequisite: exact Markdown and recursive null omission, strict HTTP 201 and original-position/body confirmation, no redirect/OAuth replay/automatic retry/public-comment fallback, and sanitized typed/status-preserving failures. Account-bound repositories require and confirm captured author plus explicit global MR identity separately from route IID. Shared text/range validation preserves published discussion behavior. Replies, commit/image/file drafts, editing/deletion, publication and the pending-review controller/UI remain follow-up work; uncertain writes require authoritative inspection before an explicit retry, and no live GitLab/device validation is claimed. [#602](https://github.com/Theorvane/labfox/issues/602) / [PR #603](https://github.com/Theorvane/labfox/pull/603) adds read-only pending-review pagination with an authoritative global MR identity separate from route IID, immutable server order, explicit cursors through empty pages, optional totals and duplicate-load prevention. Mismatched author/MR identities, duplicate drafts and backward cursors reject the full result. Account/repository generations cancel obsolete dispatch and isolate initial/pagination success/errors. Presentation immediately removes retained private rows during account/client/instance/sign-out, refresh and failed reads; explicit retry restarts page one. Offset traversal is not an atomic snapshot and cannot authorize publication. The controller exposes no UI or mutation; see [private pagination](mr-pending-review-reader.md). [#604](https://github.com/Theorvane/labfox/issues/604) / [PR #605](https://github.com/Theorvane/labfox/pull/605) adds a read-only private pending-review panel on MR detail with exact selectable saved Markdown, known original metadata, localized loading/empty/partial/error states and explicit paging/refresh/retry. Guarded queries await fresh MR detail and verify route/global identity before dispatch, closing account/instance/client replacement requests ahead of detail; old private rows disappear during detail refresh and failed reads. Five locales, three widths, both themes, large text, long content and resize/theme preservation are covered with six synthetic captures. The panel sends no mutation or embedded image request and never infers/re-anchors original positions; see [private review panel](mr-pending-review-panel.md). Creation orchestration, uncertain-write recovery and publication remain follow-ups; no live GitLab/device validation is claimed. [#606](https://github.com/Theorvane/labfox/issues/606) / [PR #607](https://github.com/Theorvane/LabFox/pull/607) adds regular private review save orchestration with fresh account-bound detail/global identity, shared discussion write/pagination reservations, exact Markdown and one confirmed create before route-scoped private-reader refresh. Uncertain writes block replay across ordinary refreshes and same-account client/repository replacement; explicit complete read-only inspection follows empty-page cursors, validates ownership/identity/uniqueness/order and waits for any dispatched write to settle. Old-session/view results and errors cannot expose private rows or dispatch further pages. The controller has no composer UI, positioned/reply save orchestration, editing/deletion or publication; see [guarded saves](mr-pending-review-save.md). Recovery is in-memory and offset inspection cannot promise exactly-once creation after a client timeout; no live-instance/device validation is claimed. [#608](https://github.com/Theorvane/LabFox/issues/608) / [PR #609](https://github.com/Theorvane/LabFox/pull/609) adds a localized regular private-note composer on MR detail with exact Markdown, guarded one-save orchestration, retained failed input, complete visible inspection and separate acknowledgement before manual retry. Account/client/repository/resource replacement discards private input and snapshots; obsolete results cannot toast or expose data. Public drafts stay intact, and same-account reopen retains uncertain-write gating. Five locales, three widths, both themes and keyboard/large-text recovery are covered with twelve synthetic captures; see [private composer](mr-pending-review-composer.md). Positioned/reply/commit saves, editing/deletion and publication remain separate; no exactly-once or live-instance/device validation is claimed. [#610](https://github.com/Theorvane/LabFox/issues/610) / [PR #611](https://github.com/Theorvane/LabFox/pull/611) adds private draft update/delete API and account-bound repository foundations: exact Markdown, explicit original text-position preservation, strict 200 target/body/position confirmation and 204 deletion acknowledgement, positive project/IID/draft identities, captured author/global-MR checks and unchanged non-body metadata. Status-first plain-wire decoding preserves typed failures even for malformed JSON; redirects, authentication replay, fallback writes and automatic retry are disabled. Original line-code-only and unsupported update anchors fail before dispatch; owned opaque/image/file drafts can be deleted by identity. See [maintenance contract](mr-draft-note-maintenance.md). Fresh target/controller reservations, localized edit/delete confirmation/recovery and publication remain follow-ups; no live-instance/device validation is claimed. [#612](https://github.com/Theorvane/LabFox/issues/612) connects private draft editing and confirmed deletion to the existing pending review panel. Shared discussion reservations, fresh detail/global identity and complete private-page comparison refuse stale or missing selected targets before one write. Uncertain results gate every private write; complete visible inspection and acknowledgement adopt a current target only for explicit retry, with obsolete session/view input and outcomes discarded. Five locales, keyboard/large text and twelve synthetic captures cover three widths and both themes; see [maintenance UI](mr-pending-review-maintenance.md). Publication and positioned/reply/commit creation remain separate; no atomic snapshot, conditional-write, durable recovery or live-instance/device guarantee is claimed. [#614](https://github.com/Theorvane/LabFox/issues/614) adds whole-review publication: complete visible private-note confirmation and consent, a fresh full-set comparison before one strict-204 account-scoped bulk POST, and uncertainty that blocks private mutations until complete private/public inspection. Manual retries require new consent; session/view changes discard old confirmation and outcomes. Five locales and three widths in both themes are covered; see [publication flow](mr-pending-review-publication.md). Single-note publication, summary/reviewer-state controls and positioned/reply/commit creation remain separate. No automatic approval/merge, atomic server snapshot, durable recovery or live-instance/device guarantee is claimed. [#616](https://github.com/Theorvane/labfox/issues/616) adds private text line/range draft creation from the MR changes screen, fresh authoritative diff selection checks, immutable selected text, session/snapshot/origin isolation, preserved public input and explicit uncertain-save inspection. See [private inline review](mr-pending-review-inline.md). [#618](https://github.com/Theorvane/LabFox/issues/618) adds selected saved-note publication through one strict-204 PUT, fresh exact target comparison, explicit consent and shared two-sided recovery. Unrelated saved notes remain private; a missing selected ID cannot publish another note. See [single-note publication](mr-pending-review-single-publication.md). [#620](https://github.com/Theorvane/LabFox/issues/620) adds whole-review submission with an exact public summary, explicit reviewed/request-changes outcomes, complete current-reviewer reads, fresh reviewer preflight comparison and postwrite outcome verification. Partial or obsolete outcomes require private/public/reviewer inspection after actual write settlement and renewed consent, including selected-note recovery entry. Five locales, three widths, both themes and keyboard/large text are covered; see [review submission](mr-review-submission.md). Formal approval remains separate; additional draft creation types are follow-ups. No atomic, durable recovery or live-instance/device guarantee is claimed. | | MW-08 | P1 | CI/CD configuration: pipeline editor, variables, triggers, schedule editing, and execution history. | In progress | [#423](https://github.com/Theorvane/labfox/issues/423), [PR #424](https://github.com/Theorvane/labfox/pull/424) cover timing editing; [#425](https://github.com/Theorvane/labfox/issues/425), [PR #426](https://github.com/Theorvane/labfox/pull/426) cover deletion; [#427](https://github.com/Theorvane/labfox/issues/427), [PR #428](https://github.com/Theorvane/labfox/pull/428) cover creation; [#429](https://github.com/Theorvane/labfox/issues/429), [PR #430](https://github.com/Theorvane/labfox/pull/430) cover ownership transfer; [#431](https://github.com/Theorvane/labfox/issues/431), [PR #432](https://github.com/Theorvane/labfox/pull/432) cover ref/active editing. These slices await review or maintainer merge. [#433](https://github.com/Theorvane/labfox/issues/433), [PR #434](https://github.com/Theorvane/labfox/pull/434) cover schedule-specific execution history in review: newest-first header pagination, retained rows/cursor on failed next pages, retry, run-now/refresh reload with inline errors, stale-request protection, and identifier-only pipeline navigation. History filters, variables/inputs, pipeline configuration editing, and triggers remain separate slices. | | MW-09 | P1 | Wiki creation/editing/deletion and history with conflict handling. | In progress | [#351](https://github.com/Theorvane/labfox/issues/351), [PR #352](https://github.com/Theorvane/labfox/pull/352) shipped Markdown page creation; [#353](https://github.com/Theorvane/labfox/issues/353), [PR #354](https://github.com/Theorvane/labfox/pull/354) shipped editing with best-effort stale-draft detection; [#373](https://github.com/Theorvane/labfox/issues/373), [PR #374](https://github.com/Theorvane/labfox/pull/374) shipped deletion with confirmation and best-effort stale-page detection; [#375](https://github.com/Theorvane/labfox/issues/375), [PR #376](https://github.com/Theorvane/labfox/pull/376) shipped Markdown template selection. [#377](https://github.com/Theorvane/labfox/issues/377), [PR #378](https://github.com/Theorvane/labfox/pull/378) cover template listing and creation in review. Page history and atomic conflict protection remain. The documented wiki REST API can retrieve a page by version SHA but does not list page history. | | MW-10 | P1 | Snippet creation/editing/deletion, files, and visibility. | In progress | [#355](https://github.com/Theorvane/labfox/issues/355), [PR #356](https://github.com/Theorvane/labfox/pull/356) shipped single-file project snippet creation; [#357](https://github.com/Theorvane/labfox/issues/357), [PR #358](https://github.com/Theorvane/labfox/pull/358) shipped deletion with confirmation; [#359](https://github.com/Theorvane/labfox/issues/359), [PR #360](https://github.com/Theorvane/labfox/pull/360) shipped title and description editing; [#361](https://github.com/Theorvane/labfox/issues/361), [PR #362](https://github.com/Theorvane/labfox/pull/362) shipped single-file content editing; [#363](https://github.com/Theorvane/labfox/issues/363), [PR #364](https://github.com/Theorvane/labfox/pull/364) shipped private/public visibility editing; [#365](https://github.com/Theorvane/labfox/issues/365), [PR #366](https://github.com/Theorvane/labfox/pull/366) shipped multi-file content editing; [#367](https://github.com/Theorvane/labfox/issues/367), [PR #368](https://github.com/Theorvane/labfox/pull/368) shipped adding a file; [#369](https://github.com/Theorvane/labfox/issues/369), [PR #370](https://github.com/Theorvane/labfox/pull/370) shipped deleting a file; [#371](https://github.com/Theorvane/labfox/issues/371), [PR #372](https://github.com/Theorvane/labfox/pull/372) shipped moving and renaming a file. Broader visibility management remains. | diff --git a/docs/mr-review-submission.md b/docs/mr-review-submission.md new file mode 100644 index 00000000..db14124d --- /dev/null +++ b/docs/mr-review-submission.md @@ -0,0 +1,86 @@ +# Submitting a pending MR review + +Issue [#620](https://github.com/Theorvane/LabFox/issues/620) extends whole-review +publication with an optional **public review summary** and explicit reviewer +outcomes: keep the current state, reviewed, or request changes. The same MR +screen and dialog serve mobile, tablet and desktop. Summary Markdown and +whitespace are preserved exactly. Editing either option resets consent. A +summary or outcome can be submitted even when no private draft notes remain. +Selected-note publication retains its identity-only command and consent. + +## API and review semantics + +The documented [bulk Draft Notes endpoint](https://docs.gitlab.com/api/draft_notes/#publish-all-pending-draft-notes) +accepts `note` and `reviewer_state` (`reviewed`, `requested_changes`). One POST +publishes the authenticated author's entire pending set. A supplied summary +uses explicit `internal: false`; no internal-summary control is offered here. +Plain publication keeps its bodyless request. The response must be HTTP 204; +redirects, authentication replay, fallback writes and automatic retries remain +disabled. + +[MR reviewers](https://docs.gitlab.com/api/merge_requests/#retrieve-merge-request-reviewers) +are read through their own paginated endpoint. The outer `state` describes the +review; nested `user.state` describes the account. The generated model retains +unknown future review states, while the UI uses a localized unknown-state label. +The complete reviewer set is validated for positive unique IDs, nonblank states +and advancing cursors. Wire user IDs must be integers before generated model +parsing, so fractional values cannot be truncated into another identity. Missing membership is a successful unassigned state, +not an unavailable endpoint. If a preparation read is unavailable, notes and a +summary remain publishable, but outcome selection is disabled. + +The current [primary API implementation](https://gitlab.com/gitlab-org/gitlab/-/raw/master/lib/api/draft_notes.rb) +publishes notes before creating the summary and applying reviewer state. These +steps can succeed separately. Its reviewer-state service result is not checked +before returning 204, so an outcome submission additionally reads all reviewer +pages after the write and confirms the current user's requested state. A +mismatch or failed read leaves the shared uncertainty gate in place. + +[Reviewer-state behavior](https://gitlab.com/gitlab-org/gitlab/-/raw/master/app/services/merge_requests/update_reviewer_state_service.rb) +is distinct from formal approval: reviewed does not approve the MR and can +clear the caller's previous change request. Requesting changes can block merging +and remove the caller's existing approval. The dialog explains both effects. +Reviewed cannot replace an existing approved state; that choice is disabled and +the controller refuses it before publication. GitLab may assign the submitting +user as a reviewer; existing reviewer membership is not required by this client. +Server permissions and edition capabilities remain authoritative. + +## Confirmation, cancellation and recovery + +The immutable preview binds the account, client, repositories, route IID and +global MR ID. Whole-review publication still compares every fresh private draft +against that preview. When an outcome is selected, a complete fresh reviewer +read must also match the preview before dispatch. Shared command reservations +prevent overlapping public or private discussion mutations. Every asynchronous +boundary checks the current session and origin before adopting results. + +Unconfirmed or obsolete writes require inspection. Actual write settlement is +awaited before staging complete private notes and public discussions. If the +attempt included reviewer state, a complete current reviewer read is mandatory, +even when recovery is entered through selected-note publication. A failed state +inspection cannot clear the gate or adopt a partial public result. Only a +complete current inspection clears the gate; new consent is then required for +another explicit submission. A new preview clears an outcome that is unavailable +or disabled, so notes and summary publication can remain available. The UI retains the unsent summary/outcome in the +current dialog and warns that they may already have been applied. Closing or +changing the account, client, repository, MR or origin discards local input. +No stored text or missing draft is interpreted as proof of exactly-once success. + +## Validation and limits + +Tests reproduce missing API options, state confirmation/recovery and UI controls +before implementation. Coverage includes empty intermediate reviewer pages, +unknown state preservation, malformed/status-first responses, exact public +payloads, state-only and summary-only submissions, stale reviewer comparisons, +partial publication, required state recovery, cancellation boundaries, actual +write settlement, renewed consent and obsolete input isolation. Existing whole +and selected publication suites remain regression checks. Five locales, three +widths and both themes are exercised, including 1.8 text scale with a 320-pixel +keyboard inset. Twelve synthetic confirmation/recovery captures are in +[the image directory](images/mr-review-submission). + +This is a paginated preflight and observed postwrite state, not an atomic server +transaction or proof that no other client intervened. Recovery remains in memory +and is not durable across app restarts. No live-instance/device validation is +claimed. No dependencies, persistence, telemetry, formal approval or merge +command are added. Model and localization outputs are generated. Additional +private reply/commit/image/file creation remains separate; MW-07 is in progress. diff --git a/packages/gitlab_api/lib/src/merge_requests/merge_requests_api.dart b/packages/gitlab_api/lib/src/merge_requests/merge_requests_api.dart index 523cad44..aff54258 100644 --- a/packages/gitlab_api/lib/src/merge_requests/merge_requests_api.dart +++ b/packages/gitlab_api/lib/src/merge_requests/merge_requests_api.dart @@ -31,6 +31,15 @@ enum MergeRequestScope { final String value; } +/// Review submission outcomes, separate from formal approval. +enum ReviewerSubmissionState { + reviewed('reviewed'), + requestedChanges('requested_changes'); + + const ReviewerSubmissionState(this.value); + final String value; +} + /// Merge request endpoints. class MergeRequestsApi { const MergeRequestsApi(this._dio); @@ -587,6 +596,84 @@ class MergeRequestsApi { } } + /// Reads review state rather than the nested user's account state. + Future> reviewers( + Object projectId, { + required int iid, + int page = 1, + int perPage = 20, + }) async { + final path = _draftNotesPath( + projectId, + iid, + ).replaceFirst('/draft_notes', '/reviewers'); + if (page < 1) throw ArgumentError.value(page, 'page'); + if (perPage < 1 || perPage > 100) { + throw ArgumentError.value(perPage, 'perPage'); + } + try { + final response = await _dio.get( + path, + queryParameters: {'page': page, 'per_page': perPage}, + options: Options( + responseType: ResponseType.plain, + followRedirects: false, + ), + ); + if (response.statusCode != 200) { + throw mapStatus( + response.statusCode, + response.headers.map, + context: 'loading reviewers', + ); + } + try { + final payload = response.data is String + ? jsonDecode(response.data as String) + : response.data; + if (payload is! List) throw const FormatException(); + final ids = {}; + final items = payload + .map((entry) { + if (entry is! Map || + entry['user'] is! Map) { + throw const FormatException(); + } + final user = entry['user'] as Map; + final id = user['id']; + if (id is! int || id < 1) throw const FormatException(); + final reviewer = MergeRequestReviewer.fromJson(entry); + if (reviewer.user.id < 1 || + reviewer.state.trim().isEmpty || + !ids.add(reviewer.user.id)) { + throw const FormatException(); + } + return reviewer; + }) + .toList(growable: false); + final cursors = response.headers['x-next-page']; + if (cursors != null && cursors.length != 1) { + throw const FormatException(); + } + final cursor = cursors?.single; + if (cursor != null && cursor.isNotEmpty) { + final next = int.tryParse(cursor); + if (next == null || next <= page) throw const FormatException(); + } + return Paginated.fromHeaders( + List.unmodifiable(items), + response.headers.map, + ); + } on FormatException { + throw const GitLabServerException('Invalid reviewers response.'); + } on TypeError { + throw const GitLabServerException('Invalid reviewers response.'); + } + } on DioException catch (error) { + throw mapError(error, context: 'loading reviewers'); + } + } + /// Saves a new unpublished regular or original text-positioned review note. /// A failed/unconfirmed response can follow a successful server write. Callers /// must inspect pending drafts before an explicit retry; this never replays. @@ -808,11 +895,30 @@ class MergeRequestsApi { /// Publishes all saved notes owned by the authenticated user, once. /// GitLab does not accept a conditional list of selected draft IDs here. - Future publishDraftNotes(Object projectId, {required int iid}) async { + Future publishDraftNotes( + Object projectId, { + required int iid, + String? summaryNote, + ReviewerSubmissionState? reviewerState, + }) async { final path = '${_draftNotesPath(projectId, iid)}/bulk_publish'; + if (summaryNote != null && summaryNote.trim().isEmpty) { + throw ArgumentError( + 'A supplied public summary must not be blank.', + 'summaryNote', + ); + } try { final response = await _dio.post( path, + data: summaryNote == null && reviewerState == null + ? null + : { + 'note': ?summaryNote, + if (summaryNote != null) 'internal': false, + if (reviewerState != null) + 'reviewer_state': reviewerState.value, + }, options: Options( responseType: ResponseType.plain, followRedirects: false, diff --git a/packages/gitlab_api/test/mr_review_submission_api_test.dart b/packages/gitlab_api/test/mr_review_submission_api_test.dart new file mode 100644 index 00000000..f3a50c44 --- /dev/null +++ b/packages/gitlab_api/test/mr_review_submission_api_test.dart @@ -0,0 +1,254 @@ +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:gitlab_api/gitlab_api.dart'; +import 'package:gitlab_models/gitlab_models.dart'; +import 'package:test/test.dart'; + +import 'mr_draft_note_maintenance_api_test.dart' show makeClient, Adapter; + +class ReviewerAdapter extends Adapter { + ReviewerAdapter(this.cursor) : super((o) => (status: 200, body: [])); + final List cursor; + @override + Future fetch( + RequestOptions o, + Stream? stream, + Future? cancel, + ) async { + final body = await super.fetch(o, stream, cancel); + body.headers['x-next-page'] = cursor; + return body; + } +} + +void main() { + for (final cursor in [ + [], + ['1'], + ['0'], + ['x'], + ['2', '3'], + ['3'], + ]) { + test('reviewer page cursor $cursor validates advancement', () async { + final dio = Dio()..httpClientAdapter = ReviewerAdapter(cursor); + final c = GitLabClient( + baseUrl: 'https://gitlab.example.com', + token: 'glpat-xxxxxxxxxxxx', + dio: dio, + ); + addTearDown(c.close); + if (cursor.length == 1 && cursor.single == '3') { + final result = await c.mergeRequests.reviewers(8, iid: 142); + expect(result.nextPage, 3); + expect(result.items, isEmpty); + expect( + () => result.items.add( + const MergeRequestReviewer( + user: User(id: 23, username: 'r', name: 'R'), + state: 'reviewed', + ), + ), + throwsUnsupportedError, + ); + } else { + await expectLater( + c.mergeRequests.reviewers(8, iid: 142), + throwsA(isA()), + ); + } + }); + } + + for (final state in [null, ...ReviewerSubmissionState.values]) { + for (final summary in [null, ' **Public summary**\n\nExact Markdown ']) { + test('one public bulk request with $state and $summary', () async { + final requests = []; + final c = makeClient((o) { + requests.add(o); + return (status: 204, body: '{private'); + }, raw: true); + addTearDown(c.close); + await c.mergeRequests.publishDraftNotes( + 8, + iid: 142, + summaryNote: summary, + reviewerState: state, + ); + final o = requests.single; + expect( + o.path, + '/projects/8/merge_requests/142/draft_notes/bulk_publish', + ); + expect(o.method, 'POST'); + expect(o.followRedirects, false); + expect(o.extra['labfox_no_auth_retry'], true); + expect(o.queryParameters, isEmpty); + expect( + o.data, + summary == null && state == null + ? null + : { + 'note': ?summary, + if (summary != null) 'internal': false, + if (state != null) 'reviewer_state': state.value, + }, + ); + }); + } + } + for (final text in ['', ' \n ']) { + test('blank explicit summary prevents dispatch', () async { + var calls = 0; + final c = makeClient((o) { + calls++; + return (status: 204, body: null); + }); + addTearDown(c.close); + await expectLater( + c.mergeRequests.publishDraftNotes(8, iid: 142, summaryNote: text), + throwsArgumentError, + ); + expect(calls, 0); + }); + } + test( + 'reviewer GET uses encoded project and preserves future review state', + () async { + late RequestOptions request; + final c = makeClient((o) { + request = o; + return ( + status: 200, + body: [ + { + 'user': { + 'id': 23, + 'username': 'reviewer', + 'name': 'Reviewer', + 'state': 'active', + }, + 'state': 'future_review', + 'created_at': '2026-10-06T00:00:00Z', + }, + ], + ); + }); + addTearDown(c.close); + final result = await c.mergeRequests.reviewers( + 'group/project +', + iid: 142, + page: 2, + perPage: 10, + ); + expect( + request.path, + '/projects/group%2Fproject%20%2B/merge_requests/142/reviewers', + ); + expect(request.method, 'GET'); + expect(request.followRedirects, false); + expect(request.queryParameters, {'page': 2, 'per_page': 10}); + expect(result.items.single.state, 'future_review'); + expect(result.items.single.user.state, 'active'); + expect(result.items.single.createdAt, DateTime.utc(2026, 10, 6)); + }, + ); + for (final id in [23.5, 23.0, '23', true, null]) { + test('reviewer wire identity must be a positive integer $id', () async { + final c = makeClient( + (o) => ( + status: 200, + body: [ + { + 'user': {'id': id, 'username': 'r', 'name': 'R'}, + 'state': 'reviewed', + }, + ], + ), + ); + addTearDown(c.close); + await expectLater( + c.mergeRequests.reviewers(8, iid: 142), + throwsA(isA()), + ); + }); + } + for (final body in [ + null, + {}, + '{private-marker', + [ + { + 'user': {'id': 23, 'username': 'r', 'name': 'R'}, + 'state': '', + }, + ], + [ + { + 'user': {'id': 0, 'username': 'r', 'name': 'R'}, + 'state': 'reviewed', + }, + ], + ]) { + test('malformed reviewer response is sanitized $body', () async { + final c = makeClient((o) => (status: 200, body: body)); + addTearDown(c.close); + await expectLater( + c.mergeRequests.reviewers(8, iid: 142), + throwsA( + isA().having( + (e) => e.toString(), + 'safe message', + isNot(contains('private-marker')), + ), + ), + ); + }); + } + for (final status in [201, 302, 401, 403, 404, 429, 500]) { + test('reviewer status $status precedes body decode', () async { + final c = makeClient( + (o) => (status: status, body: '{private-marker'), + raw: true, + ); + addTearDown(c.close); + await expectLater( + c.mergeRequests.reviewers(8, iid: 142), + throwsA(switch (status) { + 401 => isA(), + 403 => isA(), + 404 => isA(), + 429 => isA(), + _ => isA(), + }), + ); + }); + } + for (final args in [ + (0, 142, 1, 20), + (8, 0, 1, 20), + (8, 142, 0, 20), + (8, 142, 1, 0), + (8, 142, 1, 101), + ]) { + test('invalid reviewer route or pagination $args', () async { + var calls = 0; + final c = makeClient((o) { + calls++; + return (status: 200, body: []); + }); + addTearDown(c.close); + await expectLater( + c.mergeRequests.reviewers( + args.$1, + iid: args.$2, + page: args.$3, + perPage: args.$4, + ), + throwsArgumentError, + ); + expect(calls, 0); + }); + } +} diff --git a/packages/gitlab_models/lib/gitlab_models.dart b/packages/gitlab_models/lib/gitlab_models.dart index a9ad20fb..915edc3a 100644 --- a/packages/gitlab_models/lib/gitlab_models.dart +++ b/packages/gitlab_models/lib/gitlab_models.dart @@ -31,6 +31,7 @@ export 'src/merge_request.dart'; export 'src/merge_request_approvals.dart'; export 'src/merge_request_diff_version.dart'; export 'src/merge_request_draft_note.dart'; +export 'src/merge_request_reviewer.dart'; export 'src/note.dart'; export 'src/oauth_token.dart'; export 'src/package_file.dart'; diff --git a/packages/gitlab_models/lib/src/merge_request_reviewer.dart b/packages/gitlab_models/lib/src/merge_request_reviewer.dart new file mode 100644 index 00000000..ef8d4028 --- /dev/null +++ b/packages/gitlab_models/lib/src/merge_request_reviewer.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'user.dart'; +part 'merge_request_reviewer.freezed.dart'; +part 'merge_request_reviewer.g.dart'; + +/// Reviewer state belongs to the MR; nested user state belongs to the account. +@freezed +abstract class MergeRequestReviewer with _$MergeRequestReviewer { + const factory MergeRequestReviewer({ + required User user, + required String state, + @JsonKey(name: 'created_at') DateTime? createdAt, + }) = _MergeRequestReviewer; + factory MergeRequestReviewer.fromJson(Map json) => + _$MergeRequestReviewerFromJson(json); +} diff --git a/packages/gitlab_models/lib/src/merge_request_reviewer.freezed.dart b/packages/gitlab_models/lib/src/merge_request_reviewer.freezed.dart new file mode 100644 index 00000000..210db5c7 --- /dev/null +++ b/packages/gitlab_models/lib/src/merge_request_reviewer.freezed.dart @@ -0,0 +1,408 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'merge_request_reviewer.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$MergeRequestReviewer { + User get user; + String get state; + @JsonKey(name: 'created_at') + DateTime? get createdAt; + + /// Create a copy of MergeRequestReviewer + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $MergeRequestReviewerCopyWith get copyWith => + _$MergeRequestReviewerCopyWithImpl( + this as MergeRequestReviewer, + _$identity, + ); + + /// Serializes this MergeRequestReviewer to a JSON map. + Map toJson(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is MergeRequestReviewer && + (identical(other.user, user) || other.user == user) && + (identical(other.state, state) || other.state == state) && + (identical(other.createdAt, createdAt) || + other.createdAt == createdAt)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, user, state, createdAt); + + @override + String toString() { + return 'MergeRequestReviewer(user: $user, state: $state, createdAt: $createdAt)'; + } +} + +/// @nodoc +abstract mixin class $MergeRequestReviewerCopyWith<$Res> { + factory $MergeRequestReviewerCopyWith( + MergeRequestReviewer value, + $Res Function(MergeRequestReviewer) _then, + ) = _$MergeRequestReviewerCopyWithImpl; + @useResult + $Res call({ + User user, + String state, + @JsonKey(name: 'created_at') DateTime? createdAt, + }); + + $UserCopyWith<$Res> get user; +} + +/// @nodoc +class _$MergeRequestReviewerCopyWithImpl<$Res> + implements $MergeRequestReviewerCopyWith<$Res> { + _$MergeRequestReviewerCopyWithImpl(this._self, this._then); + + final MergeRequestReviewer _self; + final $Res Function(MergeRequestReviewer) _then; + + /// Create a copy of MergeRequestReviewer + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? user = null, + Object? state = null, + Object? createdAt = freezed, + }) { + return _then( + _self.copyWith( + user: null == user + ? _self.user + : user // ignore: cast_nullable_to_non_nullable + as User, + state: null == state + ? _self.state + : state // ignore: cast_nullable_to_non_nullable + as String, + createdAt: freezed == createdAt + ? _self.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + ), + ); + } + + /// Create a copy of MergeRequestReviewer + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $UserCopyWith<$Res> get user { + return $UserCopyWith<$Res>(_self.user, (value) { + return _then(_self.copyWith(user: value)); + }); + } +} + +/// Adds pattern-matching-related methods to [MergeRequestReviewer]. +extension MergeRequestReviewerPatterns on MergeRequestReviewer { + /// A variant of `map` that fallback to returning `orElse`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeMap( + TResult Function(_MergeRequestReviewer value)? $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _MergeRequestReviewer() when $default != null: + return $default(_that); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// Callbacks receives the raw object, upcasted. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case final Subclass2 value: + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult map( + TResult Function(_MergeRequestReviewer value) $default, + ) { + final _that = this; + switch (_that) { + case _MergeRequestReviewer(): + return $default(_that); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `map` that fallback to returning `null`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? mapOrNull( + TResult? Function(_MergeRequestReviewer value)? $default, + ) { + final _that = this; + switch (_that) { + case _MergeRequestReviewer() when $default != null: + return $default(_that); + case _: + return null; + } + } + + /// A variant of `when` that fallback to an `orElse` callback. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeWhen( + TResult Function( + User user, + String state, + @JsonKey(name: 'created_at') DateTime? createdAt, + )? + $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _MergeRequestReviewer() when $default != null: + return $default(_that.user, _that.state, _that.createdAt); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// As opposed to `map`, this offers destructuring. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case Subclass2(:final field2): + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult when( + TResult Function( + User user, + String state, + @JsonKey(name: 'created_at') DateTime? createdAt, + ) + $default, + ) { + final _that = this; + switch (_that) { + case _MergeRequestReviewer(): + return $default(_that.user, _that.state, _that.createdAt); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `when` that fallback to returning `null` + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? whenOrNull( + TResult? Function( + User user, + String state, + @JsonKey(name: 'created_at') DateTime? createdAt, + )? + $default, + ) { + final _that = this; + switch (_that) { + case _MergeRequestReviewer() when $default != null: + return $default(_that.user, _that.state, _that.createdAt); + case _: + return null; + } + } +} + +/// @nodoc +@JsonSerializable() +class _MergeRequestReviewer implements MergeRequestReviewer { + const _MergeRequestReviewer({ + required this.user, + required this.state, + @JsonKey(name: 'created_at') this.createdAt, + }); + factory _MergeRequestReviewer.fromJson(Map json) => + _$MergeRequestReviewerFromJson(json); + + @override + final User user; + @override + final String state; + @override + @JsonKey(name: 'created_at') + final DateTime? createdAt; + + /// Create a copy of MergeRequestReviewer + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$MergeRequestReviewerCopyWith<_MergeRequestReviewer> get copyWith => + __$MergeRequestReviewerCopyWithImpl<_MergeRequestReviewer>( + this, + _$identity, + ); + + @override + Map toJson() { + return _$MergeRequestReviewerToJson(this); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _MergeRequestReviewer && + (identical(other.user, user) || other.user == user) && + (identical(other.state, state) || other.state == state) && + (identical(other.createdAt, createdAt) || + other.createdAt == createdAt)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, user, state, createdAt); + + @override + String toString() { + return 'MergeRequestReviewer(user: $user, state: $state, createdAt: $createdAt)'; + } +} + +/// @nodoc +abstract mixin class _$MergeRequestReviewerCopyWith<$Res> + implements $MergeRequestReviewerCopyWith<$Res> { + factory _$MergeRequestReviewerCopyWith( + _MergeRequestReviewer value, + $Res Function(_MergeRequestReviewer) _then, + ) = __$MergeRequestReviewerCopyWithImpl; + @override + @useResult + $Res call({ + User user, + String state, + @JsonKey(name: 'created_at') DateTime? createdAt, + }); + + @override + $UserCopyWith<$Res> get user; +} + +/// @nodoc +class __$MergeRequestReviewerCopyWithImpl<$Res> + implements _$MergeRequestReviewerCopyWith<$Res> { + __$MergeRequestReviewerCopyWithImpl(this._self, this._then); + + final _MergeRequestReviewer _self; + final $Res Function(_MergeRequestReviewer) _then; + + /// Create a copy of MergeRequestReviewer + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? user = null, + Object? state = null, + Object? createdAt = freezed, + }) { + return _then( + _MergeRequestReviewer( + user: null == user + ? _self.user + : user // ignore: cast_nullable_to_non_nullable + as User, + state: null == state + ? _self.state + : state // ignore: cast_nullable_to_non_nullable + as String, + createdAt: freezed == createdAt + ? _self.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + ), + ); + } + + /// Create a copy of MergeRequestReviewer + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $UserCopyWith<$Res> get user { + return $UserCopyWith<$Res>(_self.user, (value) { + return _then(_self.copyWith(user: value)); + }); + } +} diff --git a/packages/gitlab_models/lib/src/merge_request_reviewer.g.dart b/packages/gitlab_models/lib/src/merge_request_reviewer.g.dart new file mode 100644 index 00000000..4883d6af --- /dev/null +++ b/packages/gitlab_models/lib/src/merge_request_reviewer.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'merge_request_reviewer.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_MergeRequestReviewer _$MergeRequestReviewerFromJson( + Map json, +) => _MergeRequestReviewer( + user: User.fromJson(json['user'] as Map), + state: json['state'] as String, + createdAt: json['created_at'] == null + ? null + : DateTime.parse(json['created_at'] as String), +); + +Map _$MergeRequestReviewerToJson( + _MergeRequestReviewer instance, +) => { + 'user': instance.user.toJson(), + 'state': instance.state, + 'created_at': instance.createdAt?.toIso8601String(), +}; diff --git a/packages/gitlab_models/test/merge_request_reviewer_test.dart b/packages/gitlab_models/test/merge_request_reviewer_test.dart new file mode 100644 index 00000000..0b97913d --- /dev/null +++ b/packages/gitlab_models/test/merge_request_reviewer_test.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; +import 'package:gitlab_models/gitlab_models.dart'; +import 'package:test/test.dart'; + +void main() { + for (final state in [ + 'unreviewed', + 'reviewed', + 'requested_changes', + 'approved', + 'unapproved', + 'review_started', + 'future_state', + ]) { + test('outer review state $state stays separate from account status', () { + final value = MergeRequestReviewer.fromJson({ + 'user': { + 'id': 23, + 'username': 'reviewer', + 'name': 'Reviewer', + 'state': 'blocked', + }, + 'state': state, + }); + expect(value.state, state); + expect(value.user.state, 'blocked'); + expect(value.createdAt, isNull); + expect( + MergeRequestReviewer.fromJson( + jsonDecode(jsonEncode(value)) as Map, + ), + value, + ); + }); + } + test( + 'review state must be present even when nested account state is present', + () { + expect( + () => MergeRequestReviewer.fromJson({ + 'user': {'id': 23, 'username': 'r', 'name': 'R', 'state': 'active'}, + }), + throwsA(isA()), + ); + }, + ); +}