Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,38 @@ class MergeRequestsRepository {
return List<Commit>.unmodifiable(items);
}

/// Refreshes MR identity through this captured account before commit review.
/// Requires the known global MR ID and a reported target project; omitted
/// source metadata stays unknown. Null means the caller's origin expired.
/// Callers recheck currency after awaiting and separately validate membership,
/// commit availability and original references before any private write.
Future<MergeRequest?> commitReviewContext({
required int projectId,
required int iid,
required int mergeRequestId,
required bool Function() isCurrent,
}) async {
if (projectId < 1) throw ArgumentError.value(projectId, 'projectId');
if (iid < 1) throw ArgumentError.value(iid, 'iid');
if (mergeRequestId < 1) {
throw ArgumentError.value(mergeRequestId, 'mergeRequestId');
}
if (!isCurrent()) return null;
final MergeRequest result;
try {
result = await _client.mergeRequests.get(projectId, iid: iid);
} on GitLabException {
if (!isCurrent()) return null;
rethrow;
}
if (!isCurrent()) return null;
if (result.id != mergeRequestId || result.targetProjectId != projectId) {
throw const GitLabServerException('Invalid MR commit review context.');
}
if (!isCurrent()) return null;
return result;
}

Future<MergeRequest> get({required int projectId, required int iid}) {
return _client.mergeRequests.get(projectId, iid: iid);
}
Expand Down
191 changes: 191 additions & 0 deletions apps/labfox/test/mr_commit_review_context_repository_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:gitlab_api/gitlab_api.dart';
import 'package:labfox/features/merge_requests/data/merge_requests_repository.dart';
import '../../../packages/gitlab_api/test/mr_commits_api_test.dart' as b;
import '../../../packages/gitlab_api/test/mr_detail_identity_api_test.dart'
as d;

void main() {
for (final source in [8, 19, null]) {
test(
'fresh captured target and global MR identity preserve source $source',
() async {
var calls = 0;
final c = b.client((o) {
calls++;
expect(o.path, '/projects/8/merge_requests/142');
expect(o.method, 'GET');
return b.response({...d.mr(), 'source_project_id': source});
});
addTearDown(c.close);
final result = await MergeRequestsRepository(c).commitReviewContext(
projectId: 8,
iid: 142,
mergeRequestId: 55123,
isCurrent: () => true,
);
expect(result!.id, 55123);
expect(result.iid, 142);
expect(result.targetProjectId, 8);
expect(result.sourceProjectId, source);
expect(calls, 1);
},
);
}
for (final change in [
{'id': 55124},
{'target_project_id': null},
{'target_project_id': 9},
{'iid': 143},
]) {
test('current identity mismatch or unknown target fails $change', () async {
final c = b.client((o) => b.response({...d.mr(), ...change}));
addTearDown(c.close);
await expectLater(
MergeRequestsRepository(c).commitReviewContext(
projectId: 8,
iid: 142,
mergeRequestId: 55123,
isCurrent: () => true,
),
throwsA(isA<GitLabServerException>()),
);
});
}
test(
'missing target is never filled from the screen project or project_id',
() async {
final body = d.mr()..remove('target_project_id');
final c = b.client((o) => b.response(body));
addTearDown(c.close);
await expectLater(
MergeRequestsRepository(c).commitReviewContext(
projectId: 8,
iid: 142,
mergeRequestId: 55123,
isCurrent: () => true,
),
throwsA(isA<GitLabServerException>()),
);
},
);
test(
'unknown project_id does not prevent a confirmed target identity',
() async {
final body = d.mr()..remove('project_id');
final c = b.client((o) => b.response(body));
addTearDown(c.close);
final result = await MergeRequestsRepository(c).commitReviewContext(
projectId: 8,
iid: 142,
mergeRequestId: 55123,
isCurrent: () => true,
);
expect(result!.projectId, isNull);
expect(result.targetProjectId, 8);
},
);
for (final (project, iid, id) in [
(0, 142, 55123),
(8, 0, 55123),
(8, 142, 0),
(-1, 142, 55123),
(8, -1, 55123),
(8, 142, -1),
]) {
test(
'invalid captured identity fails before dispatch $project $iid $id',
() async {
var calls = 0;
final c = b.client((o) {
calls++;
return b.response(d.mr());
});
addTearDown(c.close);
await expectLater(
MergeRequestsRepository(c).commitReviewContext(
projectId: project,
iid: iid,
mergeRequestId: id,
isCurrent: () => true,
),
throwsArgumentError,
);
expect(calls, 0);
},
);
}
test('already obsolete origin dispatches nothing', () async {
var calls = 0;
final c = b.client((o) {
calls++;
return b.response(d.mr());
});
addTearDown(c.close);
expect(
await MergeRequestsRepository(c).commitReviewContext(
projectId: 8,
iid: 142,
mergeRequestId: 55123,
isCurrent: () => false,
),
isNull,
);
expect(calls, 0);
});
for (final status in [200, 403, 404, 429, 500]) {
test(
'origin replacement discards late response or typed failure $status',
() async {
final started = Completer<void>(), answer = Completer<void>();
var current = true;
final c = b.client((o) async {
started.complete();
await answer.future;
return b.response(d.mr(), status: status);
});
addTearDown(c.close);
final result = MergeRequestsRepository(c).commitReviewContext(
projectId: 8,
iid: 142,
mergeRequestId: 55123,
isCurrent: () => current,
);
await Future.any<Object?>([started.future, result]);
expect(started.isCompleted, isTrue);
current = false;
answer.complete();
expect(await result, isNull);
},
);
}
test('final exposure guard discards a previously current result', () async {
var checks = 0;
final c = b.client((o) => b.response(d.mr()));
addTearDown(c.close);
expect(
await MergeRequestsRepository(c).commitReviewContext(
projectId: 8,
iid: 142,
mergeRequestId: 55123,
isCurrent: () => ++checks < 3,
),
isNull,
);
expect(checks, 3);
});
test('current forbidden failure remains typed', () async {
final c = b.client((o) => b.response('bad', status: 403, raw: true));
addTearDown(c.close);
await expectLater(
MergeRequestsRepository(c).commitReviewContext(
projectId: 8,
iid: 142,
mergeRequestId: 55123,
isCurrent: () => true,
),
throwsA(isA<GitLabForbiddenException>()),
);
});
}
2 changes: 1 addition & 1 deletion docs/mobile-web-parity.md

Large diffs are not rendered by default.

72 changes: 72 additions & 0 deletions docs/mr-commit-review-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# MR identity for original commit review

Issue [#630](https://github.com/Theorvane/LabFox/issues/630) preserves MR fork
identity and adds a captured-account detail read for future original commit
review. It exposes no UI action, private write or original diff coordinates.

## Reported fork identities

The [official single MR API](https://docs.gitlab.com/api/merge_requests/#retrieve-a-merge-request)
uses the project route and per-project `merge_request_iid`. Its response reports
global `id`, `iid`, `project_id`, `source_project_id` and `target_project_id`
separately. Generated `MergeRequest` now retains nullable source and target
project IDs alongside the existing project metadata. Fork source and target may
differ; absent or deleted source metadata remains unknown. Neither ID is filled
from the screen project, branch names or another project field.

## Detail response validation

`MergeRequestsApi.get` accepts a positive numeric project or nonblank encoded
project path and a positive IID. It preserves the label-details query, disables
redirects and checks HTTP status before decoding plain JSON. The response must
be an object with positive integer global ID and matching integer IID. Optional
project identities, when reported, must be positive integers; fractional values,
floating-point representations and coercible strings are rejected before the
generated serializer can truncate them. Reported project and target IDs must
agree with each other and with a numeric route. A slug cannot establish numeric
identity, so that read checks reported consistency without guessing its ID.
The [primary MR model](https://github.com/gitlabhq/gitlabhq/blob/master/app/models/merge_request.rb)
aliases `project_id` to `target_project_id`, confirming this consistency check.

Missing optional metadata remains compatible with general detail viewing.
Malformed payloads become sanitized domain failures, and status/transport errors
retain their typed mapping. Existing read-only OAuth recovery remains bound to
the captured account and exact route. List/search parsing is unchanged: their
metadata is not a substitute for this fresh validated detail read.

## Captured context reader

`MergeRequestsRepository.commitReviewContext` requires the captured numeric
project, IID and known global MR ID separately. It performs one detail GET
through its captured client and confirms both global MR identity and a reported
target project matching that route. Unknown target metadata fails; even a known
`project_id` is not substituted for the target field. Unknown source metadata is
retained because deletion of a fork does not establish that a selected commit is
unavailable in the target repository.

A required caller currency guard runs before dispatch, after the response or
typed failure, and before exposure. Obsolete results/errors return null; current
failures remain typed. The guard cannot cancel an already dispatched GET or its
account-bound authentication recovery. Callers must recheck after awaiting.

The [primary draft model](https://github.com/gitlabhq/gitlabhq/blob/master/app/models/draft_note.rb)
defaults its project to the MR target and validates the commit there. This
observation motivates preserving target identity; it does not prove selected
commit availability, current membership, original parent/reference semantics,
diff coverage or permission to write. No server implementation code is copied.
Sequential identity/membership/diff reads are not an atomic snapshot. Before
private writes, the future controller must validate those resources, literal
coordinates and currency, then use shared reservations and settlement/recovery.
MR-wide `diff_refs` are not original commit references and are not inferred here.

## Validation and remaining work

Test-first model/API/repository cases cover same-project and fork identities,
unknown/deleted sources, integer validation, route/global identity mismatches,
sanitized status-first errors, redirect options, read-only OAuth recovery,
obsolete successes/failures and final exposure. Freezed and JSON serializers are
regenerated together. No UI/localization, dependencies, persistence, telemetry or
private-write behavior changes. No live private-instance/device verification is
claimed. Original parent/reference validation and guarded literal selection,
private-save and recovery UI remain separate. MW-07 and image/file draft creation
remain in progress.
Original file line number Diff line number Diff line change
Expand Up @@ -412,22 +412,62 @@ class MergeRequestsApi {
}
}

/// A single merge request by its `iid`.
/// A single merge request by its `iid`, with verified reported identities.
/// Missing project metadata remains unknown; a slug is not a numeric identity.
Future<MergeRequest> get(Object projectId, {required int iid}) async {
if (!((projectId is int && projectId > 0) ||
(projectId is String && projectId.trim().isNotEmpty))) {
throw ArgumentError.value(projectId, 'projectId');
}
if (iid < 1) throw ArgumentError.value(iid, 'iid');
try {
final response = await _dio.get<Map<String, dynamic>>(
final response = await _dio.get<String>(
'/projects/${_enc(projectId)}/merge_requests/$iid',
queryParameters: {'with_labels_details': true},
options: Options(
responseType: ResponseType.plain,
followRedirects: false,
),
);
final data = response.data;
if (response.statusCode != 200 || data == null) {
if (response.statusCode != 200) {
throw mapStatus(
response.statusCode,
response.headers.map,
context: 'loading the merge request',
);
}
return MergeRequest.fromJson(data);
try {
final data = jsonDecode(response.data ?? '');
if (data is! Map<String, dynamic>) throw const FormatException();
for (final field in ['id', 'iid']) {
final value = data[field];
if (value is! int || value < 1) throw const FormatException();
}
if (data['iid'] != iid) throw const FormatException();
for (final field in [
'project_id',
'source_project_id',
'target_project_id',
]) {
final value = data[field];
if (value != null && (value is! int || value < 1)) {
throw const FormatException();
}
}
final project = data['project_id'];
final target = data['target_project_id'];
if ((project != null && target != null && project != target) ||
(projectId is int &&
((project != null && project != projectId) ||
(target != null && target != projectId)))) {
throw const FormatException();
}
return MergeRequest.fromJson(data);
} on FormatException {
throw const GitLabServerException('Invalid merge request response.');
} on TypeError {
throw const GitLabServerException('Invalid merge request response.');
}
} on DioException catch (error) {
throw mapError(error, context: 'loading the merge request');
}
Expand Down
Loading
Loading