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
23 changes: 23 additions & 0 deletions apps/labfox/lib/app/router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import '../features/members/presentation/project_members_screen.dart';
import '../features/merge_requests/presentation/merge_request_detail_screen.dart';
import '../features/merge_requests/presentation/merge_requests_screen.dart';
import '../features/merge_requests/presentation/mr_changes_screen.dart';
import '../features/merge_requests/presentation/mr_commits_screen.dart';
import '../features/merge_requests/presentation/mr_original_changes_screen.dart';
import '../features/merge_requests/presentation/my_merge_requests_screen.dart';
import '../features/merge_requests/presentation/new_merge_request_screen.dart';
import '../features/milestones/presentation/milestone_detail_screen.dart';
Expand Down Expand Up @@ -197,6 +199,10 @@ abstract final class Routes {
static String job(int id, int jobId) => '/projects/$id/jobs/$jobId';
static String commitChanges(int id, String sha) =>
'/projects/$id/commit/$sha/changes';
static String mergeRequestCommits(int id, int iid) =>
'/projects/$id/merge_requests/$iid/commits';
static String mergeRequestCommitChanges(int id, int iid, String sha) =>
'/projects/$id/merge_requests/$iid/commits/$sha';
static String mergeRequestChanges(int id, int iid) =>
'/projects/$id/merge_requests/$iid/changes';
}
Expand Down Expand Up @@ -771,6 +777,23 @@ final routerProvider = Provider<GoRouter>((ref) {
return MrChangesScreen(projectId: id, iid: iid);
},
),
GoRoute(
path: 'commits',
builder: (context, state) => MrCommitsScreen(
projectId: int.parse(state.pathParameters['id']!),
iid: int.parse(state.pathParameters['iid']!),
),
routes: [
GoRoute(
path: ':sha',
builder: (context, state) => MrOriginalChangesScreen(
projectId: int.parse(state.pathParameters['id']!),
iid: int.parse(state.pathParameters['iid']!),
commitId: state.pathParameters['sha']!,
),
),
],
),
],
),
],
Expand Down
16 changes: 12 additions & 4 deletions apps/labfox/lib/core/analytics/analytics.dart
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,19 @@ class Analytics {

/// Collapses numeric path segments (`/projects/42` → `/projects/:id`) so a
/// screen-view route never carries an identifying or high-cardinality value.
/// Commit route parameters are hidden even when an invalid deep link is visited.
String sanitizeRoute(String path) {
return path
.split('/')
.map((segment) => int.tryParse(segment) == null ? segment : ':id')
.join('/');
final segments = path.split('/');
return [
for (var i = 0; i < segments.length; i++)
if (i > 0 &&
(segments[i - 1] == 'commit' || segments[i - 1] == 'commits'))
':sha'
else if (int.tryParse(segments[i]) != null)
':id'
else
segments[i],
].join('/');
}

final analyticsProvider = Provider<Analytics>((ref) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
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';
import '../../data/merge_requests_repository.dart';
import 'merge_requests_controllers.dart';

/// Establishes the authoritative global MR identity from an identifier-only route.
class MrCommitOriginController
extends AutoDisposeFamilyAsyncNotifier<MergeRequest?, MergeRequestRef> {
@override
Future<MergeRequest?> build(MergeRequestRef arg) async {
if (arg.projectId < 1 || arg.iid < 1) {
throw ArgumentError('Invalid MR route.');
}
var active = true;
ref.onDispose(() => active = false);
final account = ref.watch(currentAccountProvider);
if (account == null) return null;
final source = ref.watch(mergeRequestsRepositoryProvider.future);
bool current() =>
active &&
ref.read(currentAccountProvider) == account &&
identical(ref.read(mergeRequestsRepositoryProvider.future), source);
try {
final repo = await source;
if (!current()) return null;
if (repo == null || account.user.id < 1) {
throw const GitLabAuthException(
'No authenticated commit review session.',
);
}
final detail = await repo.get(projectId: arg.projectId, iid: arg.iid);
if (!current()) return null;
if (detail.id < 1 ||
detail.iid != arg.iid ||
detail.targetProjectId != arg.projectId ||
(detail.projectId != null && detail.projectId != arg.projectId)) {
throw const GitLabServerException('Invalid commit review identity.');
}
return detail;
} catch (_) {
if (!current()) return null;
rethrow;
}
}
}

final mrCommitOriginControllerProvider = AsyncNotifierProvider.autoDispose
.family<MrCommitOriginController, MergeRequest?, MergeRequestRef>(
MrCommitOriginController.new,
);

/// Browses every advertised membership page without exposing partial stale lists.
class MrCommitsController
extends AutoDisposeFamilyAsyncNotifier<List<Commit>?, MergeRequestRef> {
@override
Future<List<Commit>?> build(MergeRequestRef arg) async {
var active = true;
ref.onDispose(() => active = false);
final account = ref.watch(currentAccountProvider);
if (account == null) return null;
final origin = ref.watch(mrCommitOriginControllerProvider(arg).future);
final source = ref.watch(mergeRequestsRepositoryProvider.future);
bool current() =>
active &&
ref.read(currentAccountProvider) == account &&
identical(ref.read(mergeRequestsRepositoryProvider.future), source) &&
identical(
ref.read(mrCommitOriginControllerProvider(arg).future),
origin,
);
try {
// Observe both started futures immediately, even if one fails first.
final values = await Future.wait<Object?>([
origin,
source,
], eagerError: true);
if (!current() || values[0] == null) return null;
final repo = values[1] as MergeRequestsRepository?;
if (repo == null) {
throw const GitLabAuthException('No authenticated commit browser.');
}
final commits = await repo.commits(
projectId: arg.projectId,
iid: arg.iid,
isCurrent: current,
);
if (!current() || commits == null) return null;
return List<Commit>.unmodifiable(commits);
} catch (_) {
if (!current()) return null;
rethrow;
}
}
}

final mrCommitsControllerProvider = AsyncNotifierProvider.autoDispose
.family<MrCommitsController, List<Commit>?, MergeRequestRef>(
MrCommitsController.new,
);
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ class MergeRequestDetailScreen extends ConsumerWidget {
icon: const Icon(LabFoxIcons.diff, size: 18),
label: Text(l10n.mrViewChanges),
),
const SizedBox(height: LabFoxSpacing.sm),
OutlinedButton.icon(
onPressed: () =>
context.push(Routes.mergeRequestCommits(projectId, iid)),
icon: const Icon(LabFoxIcons.commit),
label: Text(l10n.commitsTitle),
),
if (data.labels.isNotEmpty) ...[
const SizedBox(height: LabFoxSpacing.md),
Wrap(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/router.dart';
import '../../../l10n/app_localizations.dart';
import 'controllers/merge_requests_controllers.dart';
import 'controllers/mr_commits_controller.dart';

/// Lists complete MR commit membership and navigates by identifiers only.
class MrCommitsScreen extends ConsumerWidget {
const MrCommitsScreen({
required this.projectId,
required this.iid,
super.key,
});
final int projectId, iid;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
final resource = MergeRequestRef(projectId: projectId, iid: iid);
final provider = mrCommitsControllerProvider(resource);
return Scaffold(
appBar: AppBar(title: Text(l.commitsTitle)),
body: ref
.watch(provider)
.when(
skipLoadingOnRefresh: false,
skipLoadingOnReload: false,
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(l.commitsError),
TextButton(
onPressed: () {
ref.invalidate(
mrCommitOriginControllerProvider(resource),
);
ref.invalidate(provider);
},
child: Text(l.retry),
),
],
),
),
data: (commits) {
if (commits == null) {
return Center(child: Text(l.mrReviewUnavailable));
}
if (commits.isEmpty) return Center(child: Text(l.mrCommitsEmpty));
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: LabFoxBreakpoints.desktop,
),
child: RefreshIndicator(
onRefresh: () async {
ref.invalidate(
mrCommitOriginControllerProvider(resource),
);
ref.invalidate(provider);
try {
await ref.read(provider.future);
} catch (_) {
/* The provider displays a sanitized read error. */
}
},
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: commits.length,
itemBuilder: (_, i) {
final c = commits[i];
final canonical = RegExp(
r'^[0-9a-f]{40}$',
).hasMatch(c.id);
return ListTile(
title: Text(
c.title.isEmpty ? c.id : c.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
canonical ? c.id.substring(0, 8) : c.id,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: canonical
? () => context.push(
Routes.mergeRequestCommitChanges(
projectId,
iid,
c.id,
),
)
: null,
);
},
),
),
),
);
},
),
);
}
}
Loading
Loading