Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 bb29b37257 feat: copy button for sync log entries with stack trace and device info (#210)
- Add copy button to each sync log tile; copies a markdown summary of the
  entry plus the full About section (app version, platform, device info).
- Store stack trace and isPermanent flag on error entries (schema v33) so
  bug reports contain enough context to diagnose device-specific failures
  like MissingPluginException on Android.
- Add Android device info (manufacturer, model, OS version) to the About
  screen via device_info_plus; shared with the sync log copy via a new
  lib/ui/utils/about_markdown.dart utility.
- Show isPermanent in the subtitle ("Error (permanent)") and in the
  copied markdown.
- Display stack trace in red monospace in the expanded tile view.
- Update migration tests to assert schema v33 columns exist.
- Update fake SyncLogRepository implementations in tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 12:42:42 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 4f9a4e666f fix: make ChangeLog screen testable with DefaultAssetBundle (#198)
Switch ChangeLogScreen from rootBundle to DefaultAssetBundle.of(context)
so the asset source can be overridden in widget tests. Add
test/widget/changelog_screen_test.dart with a happy-path test (asset loads
and content is rendered) and an error-path test (missing asset shows the
error message).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:51:41 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 f962168c1a fix(agent_loop): harden loop against redundant agents, bad merges, wrong closures
- Add merge verification to the pending_issue PR path (section 2), matching
  the catch-up scan fix: if the PR is still open after _merge_pr returns, set
  State/Question instead of claiming success and leaving the issue closed with
  an unmerged PR.

- Replace _latest_ci_run() with _latest_main_ci_run() that filters to
  non-pull_request events on the 'main' prettyref.  The old limit=1 query
  could return a PR-branch run, causing section 3 to misread CI as failed
  and spawn a ci-fix agent when main was actually fine.

- Guard against redundant ci-fix agents: when the same main CI run ID has been
  failing since the previous ci-fix started (agent pushed to a branch, not
  main), check for any in-flight CI run before spawning another agent.

- Issue agent prompt: explicitly forbid "Closes #N" / "Fixes #N" in commit
  messages.  The loop is responsible for closing issues after CI passes;
  commit-keyword auto-close would race with or bypass that logic.

- Global ci-fix prompt: restore "push directly to main" (ci-fix agents need to
  land on main to clear the main CI run) and keep the "no issue references"
  guard added in the previous commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:09:32 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 ff488746a0 fix: show git hash as clickable link above stacktrace on crash screen (#201)
Previous PRs (#150, #179) added partial implementations that left duplicate
code via a rebase conflict: plain (non-linked) text above the stacktrace and a
clickable link section below it. This consolidates both into a single clickable
link above the stacktrace.

Also makes `gitHash` an injectable constructor parameter so tests can exercise
the link without needing a release build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:55:38 +02:00
20 changed files with 568 additions and 110 deletions
@@ -19,6 +19,8 @@ class SyncLogEntry {
required this.id,
required this.result,
this.errorMessage,
this.stackTrace,
this.isPermanent,
required this.protocol,
required this.emailsFetched,
required this.emailsSkipped,
@@ -34,6 +36,8 @@ class SyncLogEntry {
final int id;
final String result; // 'ok' or 'error'
final String? errorMessage;
final String? stackTrace;
final bool? isPermanent;
final String protocol; // 'imap' or 'jmap'
final int emailsFetched;
final int emailsSkipped;
@@ -54,6 +58,8 @@ abstract class SyncLogRepository {
required String accountId,
required bool success,
String? errorMessage,
String? stackTrace,
bool? isPermanent,
required String protocol,
required int emailsFetched,
required int emailsSkipped,
@@ -81,6 +87,8 @@ class NoOpSyncLogRepository implements SyncLogRepository {
required String accountId,
required bool success,
String? errorMessage,
String? stackTrace,
bool? isPermanent,
required String protocol,
required int emailsFetched,
required int emailsSkipped,
+4
View File
@@ -260,6 +260,8 @@ class _AccountSync implements _SyncLoop {
accountId: account.id,
success: false,
errorMessage: e.toString(),
stackTrace: st.toString(),
isPermanent: isPermanent,
protocol: 'imap',
emailsFetched: 0,
emailsSkipped: 0,
@@ -513,6 +515,8 @@ class _JmapAccountSync implements _SyncLoop {
accountId: account.id,
success: false,
errorMessage: e.toString(),
stackTrace: st.toString(),
isPermanent: isPermanent,
protocol: 'jmap',
emailsFetched: 0,
emailsSkipped: 0,
+9 -1
View File
@@ -192,6 +192,10 @@ class SyncLogs extends Table {
DateTimeColumn get finishedAt => dateTime()();
// Added in schema v13: raw protocol log when account.verbose == true.
TextColumn get protocolLog => text().nullable()();
// Added in schema v33: stack trace captured when an error occurs.
TextColumn get stackTrace => text().nullable()();
// Added in schema v33: whether the sync loop stopped permanently after this error.
BoolColumn get isPermanent => boolean().nullable()();
}
/// Per-mailbox breakdown for a single sync cycle.
@@ -329,7 +333,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 32;
int get schemaVersion => 33;
Future<void> _createEmailFts() async {
await customStatement('''
@@ -570,6 +574,10 @@ class AppDatabase extends _$AppDatabase {
if (from < 32) {
await m.createTable(localSieveApplied);
}
if (from >= 7 && from < 33) {
await m.addColumn(syncLogs, syncLogs.stackTrace);
await m.addColumn(syncLogs, syncLogs.isPermanent);
}
},
);
}
@@ -13,6 +13,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
required String accountId,
required bool success,
String? errorMessage,
String? stackTrace,
bool? isPermanent,
required String protocol,
required int emailsFetched,
required int emailsSkipped,
@@ -30,6 +32,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
accountId: accountId,
result: success ? 'ok' : 'error',
errorMessage: Value(errorMessage),
stackTrace: Value(stackTrace),
isPermanent: Value(isPermanent),
protocol: Value(protocol),
itemsSynced: Value(emailsFetched),
emailsSkipped: Value(emailsSkipped),
@@ -75,6 +79,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
id: r.id,
result: r.result,
errorMessage: r.errorMessage,
stackTrace: r.stackTrace,
isPermanent: r.isPermanent,
protocol: r.protocol,
emailsFetched: r.itemsSynced,
emailsSkipped: r.emailsSkipped,
+25 -50
View File
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -8,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/utils/about_markdown.dart';
import 'package:url_launcher/url_launcher.dart';
class AboutScreen extends ConsumerStatefulWidget {
@@ -19,57 +19,16 @@ class AboutScreen extends ConsumerStatefulWidget {
class _AboutScreenState extends ConsumerState<AboutScreen> {
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
late final Future<Map<String, String>> _deviceInfoFuture =
fetchAndroidDeviceInfo();
late final Stream<List<Account>> _accountsStream;
static const _gitHash = String.fromEnvironment('GIT_HASH');
@override
void initState() {
super.initState();
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
}
String _buildMarkdown(
BuildContext context,
PackageInfo? pkg,
int imapCount,
int jmapCount,
) {
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final physW = (size.width * pixelRatio).toInt();
final physH = (size.height * pixelRatio).toInt();
final version =
pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
final versionDisplay = _gitHash.isNotEmpty
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
: version;
final osName = _capitalize(Platform.operatingSystem);
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
final gitCommitLine = _gitHash.isNotEmpty
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
: '';
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
'| Property | Value |\n'
'|----------|-------|\n'
'| App Version | $versionDisplay |\n'
'$gitCommitLine'
'| Platform | ${Platform.operatingSystem} |\n'
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
'| Resolution | ${physW}x$physH px'
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
'| Dart Version | ${Platform.version.split(' ').first} |\n'
'| Processors | ${Platform.numberOfProcessors} |\n'
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
'| IMAP Accounts | $imapCount |\n'
'| JMAP Accounts | $jmapCount |\n';
}
static String _capitalize(String s) =>
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
Future<void> _copyToClipboard(
BuildContext context,
int imapCount,
@@ -79,10 +38,12 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
try {
pkg = await _packageInfoFuture;
} catch (_) {}
final deviceInfo = await _deviceInfoFuture;
if (!context.mounted) return;
await Clipboard.setData(
ClipboardData(
text: _buildMarkdown(context, pkg, imapCount, jmapCount),
text:
buildAboutMarkdown(context, pkg, imapCount, jmapCount, deviceInfo),
),
);
if (context.mounted) {
@@ -104,9 +65,10 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
try {
pkg = await _packageInfoFuture;
} catch (_) {}
final deviceInfo = await _deviceInfoFuture;
if (!context.mounted) return;
final body = Uri.encodeComponent(
_buildMarkdown(context, pkg, imapCount, jmapCount),
buildAboutMarkdown(context, pkg, imapCount, jmapCount, deviceInfo),
);
final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
@@ -150,18 +112,31 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
body: Column(
children: [
Expanded(
child: FutureBuilder<PackageInfo>(
future: _packageInfoFuture,
child: FutureBuilder<(PackageInfo?, Map<String, String>)>(
future: Future.wait([
_packageInfoFuture.then<PackageInfo?>((p) => p).catchError(
(_) => null,
),
_deviceInfoFuture,
]).then(
(results) => (
results[0] as PackageInfo?,
results[1] as Map<String, String>,
),
),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final pkg = snapshot.data?.$1;
final deviceInfo = snapshot.data?.$2 ?? {};
return Markdown(
data: _buildMarkdown(
data: buildAboutMarkdown(
context,
snapshot.data,
pkg,
imapCount,
jmapCount,
deviceInfo,
),
selectable: true,
onTapLink: (text, href, title) {
+2 -2
View File
@@ -1,7 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -13,7 +12,8 @@ class ChangeLogScreen extends StatelessWidget {
return Scaffold(
appBar: AppBar(title: const Text('ChangeLog')),
body: FutureBuilder<String>(
future: rootBundle.loadString('assets/changelog.txt'),
future:
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
+24 -35
View File
@@ -10,12 +10,12 @@ class CrashScreen extends StatelessWidget {
super.key,
required this.exception,
required this.stackTrace,
this.gitHash = const String.fromEnvironment('GIT_HASH'),
});
final Object exception;
final StackTrace? stackTrace;
static const _gitHash = String.fromEnvironment('GIT_HASH');
final String gitHash;
Future<String> _buildReport() async {
String version = 'unknown';
@@ -25,8 +25,8 @@ class CrashScreen extends StatelessWidget {
} catch (_) {}
final platform =
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
final gitLine = _gitHash.isNotEmpty
? 'Git Commit: [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)\n'
final gitLine = gitHash.isNotEmpty
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
: '';
return 'App Version: $version\n'
'$gitLine'
@@ -56,12 +56,27 @@ class CrashScreen extends StatelessWidget {
style: Theme.of(ctx).textTheme.titleMedium,
textAlign: TextAlign.center,
),
if (_gitHash.isNotEmpty) ...[
if (gitHash.isNotEmpty) ...[
const SizedBox(height: 8),
const Text(
'Git Commit: $_gitHash',
style: TextStyle(fontSize: 12, color: Colors.grey),
textAlign: TextAlign.center,
GestureDetector(
onTap: () async {
final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/commit/$gitHash',
);
await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
},
child: Text(
'Git Commit: $gitHash',
style: const TextStyle(
fontSize: 12,
color: Colors.blue,
decoration: TextDecoration.underline,
),
textAlign: TextAlign.center,
),
),
],
const SizedBox(height: 24),
@@ -106,32 +121,6 @@ class CrashScreen extends StatelessWidget {
),
),
],
if (_gitHash.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
'Git Commit:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
GestureDetector(
onTap: () async {
final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/commit/$_gitHash',
);
await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
},
child: const Text(
_gitHash,
style: TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
),
),
],
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () async {
+144 -3
View File
@@ -1,11 +1,15 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/utils/about_markdown.dart';
final _timeFmt = DateFormat('MMM d, HH:mm:ss');
@@ -21,6 +25,81 @@ String _fmtBytes(int bytes) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
/// Generates a markdown string for a single sync log entry.
String buildSyncLogEntryMarkdown(SyncLogEntry entry) {
final timeFmt = DateFormat('MMM d, HH:mm:ss');
final proto =
entry.protocol.isEmpty ? '' : ' (${entry.protocol.toUpperCase()})';
final title = entry.isOk
? 'Sync OK — ${timeFmt.format(entry.startedAt)}$proto'
: 'Sync Error — ${timeFmt.format(entry.startedAt)}$proto';
final resultLabel = entry.isOk
? 'OK'
: entry.isPermanent == true
? 'Error (permanent — sync stopped)'
: 'Error (will retry)';
final buf = StringBuffer();
buf.writeln('## $title');
buf.writeln();
buf.writeln('| Property | Value |');
buf.writeln('|----------|-------|');
buf.writeln('| Result | $resultLabel |');
if (entry.protocol.isNotEmpty) {
buf.writeln('| Protocol | ${entry.protocol.toUpperCase()} |');
}
buf.writeln('| Started | ${timeFmt.format(entry.startedAt)} |');
buf.writeln('| Finished | ${timeFmt.format(entry.finishedAt)} |');
buf.writeln('| Duration | ${_fmtDuration(entry.duration)} |');
buf.writeln('| Emails fetched | ${entry.emailsFetched} |');
buf.writeln('| Emails up-to-date | ${entry.emailsSkipped} |');
buf.writeln('| Mailboxes synced | ${entry.mailboxesSynced} |');
buf.writeln('| Pending changes flushed | ${entry.pendingFlushed} |');
buf.writeln('| Data transferred | ${_fmtBytes(entry.bytesTransferred)} |');
if (entry.mailboxStats.isNotEmpty) {
buf.writeln();
buf.writeln('### Per Mailbox');
buf.writeln();
buf.writeln('| Mailbox | Fetched | Up-to-date | Duration |');
buf.writeln('|---------|---------|------------|----------|');
for (final m in entry.mailboxStats) {
final dur = m.duration != null ? _fmtDuration(m.duration!) : '';
buf.writeln('| ${m.mailboxPath} | ${m.fetched} | ${m.skipped} | $dur |');
}
}
if (entry.errorMessage != null) {
buf.writeln();
buf.writeln('### Error Message');
buf.writeln();
buf.writeln('```');
buf.writeln(entry.errorMessage);
buf.writeln('```');
}
if (entry.stackTrace != null) {
buf.writeln();
buf.writeln('### Stack Trace');
buf.writeln();
buf.writeln('```');
buf.writeln(entry.stackTrace);
buf.writeln('```');
}
if (entry.protocolLog != null) {
buf.writeln();
buf.writeln('### Protocol Log');
buf.writeln();
buf.writeln('```');
buf.writeln(entry.protocolLog);
buf.writeln('```');
}
return buf.toString().trimRight();
}
class SyncLogScreen extends ConsumerStatefulWidget {
const SyncLogScreen({super.key, required this.accountId});
@@ -69,6 +148,41 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
ref.read(syncManagerProvider).syncNow(widget.accountId);
}
Future<void> _copyEntry(SyncLogEntry entry) async {
PackageInfo? pkg;
try {
pkg = await PackageInfo.fromPlatform();
} catch (_) {}
final deviceInfo = await fetchAndroidDeviceInfo();
if (!mounted) return;
final accounts =
await ref.read(accountRepositoryProvider).observeAccounts().first;
final imapCount = accounts.where((a) => a.type == AccountType.imap).length;
final jmapCount = accounts.where((a) => a.type == AccountType.jmap).length;
if (!mounted) return;
final entryMd = buildSyncLogEntryMarkdown(entry);
final aboutMd = buildAboutMarkdown(
context,
pkg,
imapCount,
jmapCount,
deviceInfo,
);
await Clipboard.setData(
ClipboardData(text: '$entryMd\n\n---\n\n$aboutMd'),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
duration: Duration(seconds: 3),
content: Text('Copied to clipboard'),
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -96,16 +210,20 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
? const Center(child: Text('No sync entries yet'))
: ListView.builder(
itemCount: _entries.length,
itemBuilder: (ctx, i) => _SyncLogTile(entry: _entries[i]),
itemBuilder: (ctx, i) => _SyncLogTile(
entry: _entries[i],
onCopy: () => unawaited(_copyEntry(_entries[i])),
),
),
);
}
}
class _SyncLogTile extends StatelessWidget {
const _SyncLogTile({required this.entry});
const _SyncLogTile({required this.entry, required this.onCopy});
final SyncLogEntry entry;
final VoidCallback onCopy;
@override
Widget build(BuildContext context) {
@@ -127,9 +245,20 @@ class _SyncLogTile extends StatelessWidget {
subtitle: Text(
entry.isOk
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
: 'Error · took $durationLabel',
: 'Error${entry.isPermanent == true ? ' (permanent)' : ''} · took $durationLabel',
style: TextStyle(fontSize: 12, color: entry.isOk ? null : errorColor),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.copy, size: 18),
tooltip: 'Copy to clipboard',
onPressed: onCopy,
),
const Icon(Icons.expand_more),
],
),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(72, 0, 16, 12),
@@ -171,6 +300,18 @@ class _SyncLogTile extends StatelessWidget {
style: TextStyle(color: errorColor, fontSize: 12),
),
),
if (entry.stackTrace != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
entry.stackTrace!,
style: TextStyle(
color: errorColor,
fontSize: 10,
fontFamily: 'monospace',
),
),
),
if (entry.protocolLog != null) ...[
const Padding(
padding: EdgeInsets.only(top: 6, bottom: 2),
+73
View File
@@ -0,0 +1,73 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
const _gitHash = String.fromEnvironment('GIT_HASH');
/// Returns Android device info (manufacturer, model, OS release).
/// Returns an empty map on non-Android platforms or if the plugin fails.
Future<Map<String, String>> fetchAndroidDeviceInfo() async {
if (!Platform.isAndroid) return {};
try {
final info = await DeviceInfoPlugin().androidInfo;
return {
'Manufacturer': info.manufacturer,
'Model': info.model,
'Android Version': info.version.release,
};
} catch (_) {
return {};
}
}
String _capitalize(String s) =>
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
/// Builds the standard "about" markdown table for sharing / bug reports.
///
/// Pass [deviceInfo] from [fetchAndroidDeviceInfo].
/// Pass [imapCount] and [jmapCount] when available; both default to 0.
String buildAboutMarkdown(
BuildContext context,
PackageInfo? pkg,
int imapCount,
int jmapCount,
Map<String, String> deviceInfo,
) {
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final physW = (size.width * pixelRatio).toInt();
final physH = (size.height * pixelRatio).toInt();
final version = pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
final versionDisplay = _gitHash.isNotEmpty
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
: version;
final osName = _capitalize(Platform.operatingSystem);
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
final gitCommitLine = _gitHash.isNotEmpty
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
: '';
final deviceLines =
deviceInfo.entries.map((e) => '| ${e.key} | ${e.value} |\n').join();
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
'| Property | Value |\n'
'|----------|-------|\n'
'| App Version | $versionDisplay |\n'
'$gitCommitLine'
'| Platform | ${Platform.operatingSystem} |\n'
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
'$deviceLines'
'| Resolution | ${physW}x$physH px'
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
'| Dart Version | ${Platform.version.split(' ').first} |\n'
'| Processors | ${Platform.numberOfProcessors} |\n'
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
'| IMAP Accounts | $imapCount |\n'
'| JMAP Accounts | $jmapCount |\n';
}
+24
View File
@@ -249,6 +249,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.12"
device_info_plus:
dependency: "direct main"
description:
name: device_info_plus
sha256: "6a642e1daa10190af89ba6cb6386c0df7d071a3592080bfe1e44faa63ae1df65"
url: "https://pub.dev"
source: hosted
version: "13.1.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46"
url: "https://pub.dev"
source: hosted
version: "8.1.0"
drift:
dependency: "direct main"
description:
@@ -1284,6 +1300,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.3.0"
win32_registry:
dependency: transitive
description:
name: win32_registry
sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
workmanager:
dependency: "direct main"
description:
+1
View File
@@ -61,6 +61,7 @@ dependencies:
# App version metadata for crash reports
package_info_plus: ^10.1.0
share_plus: ^13.1.0
device_info_plus: ^13.1.0
dev_dependencies:
flutter_test:
+66 -17
View File
@@ -8,12 +8,15 @@ Flow
a. Age > 1 h → kill it, set its issue to State/Question, exit 1
b. Age ≤ 1 h → print status, exit 0 (let it keep working)
2. No agent running → extract pending_issue from state (if any), then check CI
a. CI is running → save pending-ci state, exit 0
b. Latest CI failed → start fix-CI agent (preserving pending_issue), exit 0
c. CI ok + pending_issue → close the issue (CI passed), exit 0
d. CI ok (or no run yet) → find oldest Ready issue, start issue agent,
save state, exit 0
e. No Ready issues → print "nothing to do", exit 0
a. pending_issue + open PR → check PR branch CI, merge/fix/wait as needed
b. Catch-up: orphaned issue-N-fix PRs with passing CI → merge them
c. Main CI running → save pending-ci state, exit 0
d. Main CI failed → start fix-CI agent (pushes fix to main), exit 0
e. Main CI ok + pending_issue → close the issue, exit 0 (dead code path —
section 2a always returns first)
f. Main CI ok (or no run yet) → find oldest Ready issue, start issue agent,
save state, exit 0
g. No Ready issues → print "nothing to do", exit 0
Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
@@ -142,10 +145,19 @@ def _ready_issues() -> list[dict]:
return ready
def _latest_ci_run() -> dict | None:
data = _tea_get(f"repos/{REPO}/actions/runs?limit=1")
def _latest_main_ci_run() -> dict | None:
"""Return the latest CI run on the main branch (excludes PR runs).
Using the global latest run (limit=1) is wrong: a passing or failing run
on a PR branch could mask the true state of main. We filter to non-PR
events on the 'main' prettyref so section-3 logic only reacts to main.
"""
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
runs = (data or {}).get("workflow_runs", [])
return runs[0] if runs else None
for run in runs:
if run.get("event") != "pull_request" and run.get("prettyref") == "main":
return run
return None
def _latest_ci_run_for_branch(branch: str) -> dict | None:
@@ -550,7 +562,25 @@ def _run_loop() -> int:
# CI passed on the PR branch — squash-merge and close.
print(f"CI passed {_ci_run_url(pr_run['id'])} on branch {branch!r} — merging PR #{pr_number}.")
_merge_pr(pr_number)
try:
_merge_pr(pr_number)
except RuntimeError as e:
print(f"Merge of PR #{pr_number} failed: {e} — setting to State/Question.")
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
_comment_issue(
pending_issue,
f"Automatic merge of PR #{pr_number} failed: {e}. Please merge manually.",
)
return 0
if _find_pr_for_branch(branch):
print(f"PR #{pr_number} is still open after merge attempt — setting to State/Question.")
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
_comment_issue(
pending_issue,
f"Automatic merge of PR #{pr_number} failed (PR is still open after the "
"merge command). Please merge manually.",
)
return 0
_close_issue(pending_issue)
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
return 0
@@ -627,8 +657,8 @@ def _run_loop() -> int:
print(f"Merged PR #{pr_number}.")
return 0
# ── 3. Global CI check (agent pushed to main, or no pending issue) ────────
run = _latest_ci_run()
# ── 3. Global CI check (main branch only) ────────────────────────────────
run = _latest_main_ci_run()
if run and run.get("status") == "running":
print(f"CI run {_ci_run_url(run['id'])} is still running. Waiting.")
@@ -637,17 +667,33 @@ def _run_loop() -> int:
return 0
if run and run.get("status") in ("failure", "error"):
# Guard: if the same main CI run has been failing since the last ci-fix
# agent started, that agent pushed to a branch instead of main. Before
# spawning another agent, check whether any CI run is currently in
# progress (the branch run) and wait if so.
if ci_run_id_at_start is not None and run["id"] == ci_run_id_at_start:
check = _tea_get(f"repos/{REPO}/actions/runs?limit=5")
in_flight = [
r for r in (check or {}).get("workflow_runs", [])
if r.get("status") == "running"
]
if in_flight:
print(
f"Main CI still shows the same failed run {run['id']}; "
f"{_ci_run_url(in_flight[0]['id'])} is running "
"(previous ci-fix pushed to a branch). Waiting."
)
return 0
print(f"CI run {_ci_run_url(run['id'])} failed — starting fix agent.")
prompt = (
"The Codeberg CI for guettli/sharedinbox just failed. "
"The Codeberg CI for guettli/sharedinbox just failed on the main branch. "
f"The CI run ID is {run['id']}. "
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
"Identify the failure, fix it, commit, and push. "
"Identify the failure, fix it, commit, and push directly to main. "
"Verify locally with 'task check' before pushing. "
"Do NOT push to main. "
"Do NOT reference any issue numbers in commit messages "
"(no 'closes #N', 'fixes #N', or similar) — this is a CI fix, "
"not an issue fix, and auto-closing the wrong issue would be a bug. "
"not an issue fix, and auto-closing an issue via a commit message would be a bug. "
"Do NOT close any issues. "
"When done, stop."
)
@@ -712,7 +758,10 @@ Instructions:
- Implement the required change, following the existing code style.
- Write or update tests as appropriate.
- Run 'task check' locally and fix any failures before committing.
- Commit with a descriptive message referencing the issue number (e.g. "feat: ... (#{issue_number})").
- Commit with a descriptive message and include (#{issue_number}) in the title,
e.g. "feat: description (#{issue_number})".
Do NOT use "Closes #N" or "Fixes #N" keywords — the loop closes the issue
after CI passes; using those keywords would close it prematurely or wrongly.
- Create a branch named `issue-{issue_number}-fix`, push your changes there, and open a PR against main:
git checkout -b issue-{issue_number}-fix
git push -u origin issue-{issue_number}-fix
+1
View File
@@ -57,6 +57,7 @@ const _excluded = {
'lib/ui/widgets/try_connection_button.dart',
'lib/ui/widgets/undo_shell.dart',
'lib/ui/screens/about_screen.dart',
'lib/ui/utils/about_markdown.dart',
'lib/ui/widgets/email_tile.dart',
'lib/core/sync/account_sync_manager.dart',
'lib/core/sync/background_sync.dart',
@@ -288,6 +288,8 @@ class _FakeLogs implements SyncLogRepository {
required String accountId,
required bool success,
String? errorMessage,
String? stackTrace,
bool? isPermanent,
required String protocol,
required int emailsFetched,
required int emailsSkipped,
+2
View File
@@ -181,6 +181,8 @@ class FakeSyncLogRepository implements SyncLogRepository {
required String accountId,
required bool success,
String? errorMessage,
String? stackTrace,
bool? isPermanent,
required String protocol,
required int emailsFetched,
required int emailsSkipped,
+14 -2
View File
@@ -14,7 +14,7 @@ void main() {
group('Migration', () {
test('schemaVersion matches expected value', () async {
final db = AppDatabase(NativeDatabase.memory());
expect(db.schemaVersion, 32);
expect(db.schemaVersion, 33);
await db.close();
});
@@ -194,6 +194,10 @@ void main() {
// v32: local_sieve_applied table.
await db.customSelect('SELECT count(*) FROM local_sieve_applied').get();
// v33: stack_trace and is_permanent columns on sync_logs.
final syncLogColumns = await _tableColumns(db, 'sync_logs');
expect(syncLogColumns, containsAll(['stack_trace', 'is_permanent']));
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
});
@@ -381,11 +385,15 @@ void main() {
await _tableColumns(db, 'sync_log_mailboxes');
expect(syncLogMailboxColumns, contains('duration_ms'));
// v33: stack_trace and is_permanent columns on sync_logs.
final syncLogColumns = await _tableColumns(db, 'sync_logs');
expect(syncLogColumns, containsAll(['stack_trace', 'is_permanent']));
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
});
test('fresh install creates all tables at schemaVersion 32', () async {
test('fresh install creates all tables at schemaVersion 33', () async {
final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get();
@@ -426,6 +434,10 @@ void main() {
await _tableColumns(db, 'sync_log_mailboxes');
expect(syncLogMailboxColumns, contains('duration_ms'));
// v33: stack_trace and is_permanent columns on sync_logs.
final syncLogColumns = await _tableColumns(db, 'sync_logs');
expect(syncLogColumns, containsAll(['stack_trace', 'is_permanent']));
await db.close();
});
});
+2
View File
@@ -170,6 +170,8 @@ class _FakeSyncLog implements SyncLogRepository {
required String accountId,
required bool success,
String? errorMessage,
String? stackTrace,
bool? isPermanent,
required String protocol,
required int emailsFetched,
required int emailsSkipped,
@@ -126,4 +126,56 @@ void main() {
expect(rows.first.result, 'error');
expect(rows.first.errorMessage, 'Connection refused');
});
test('stores and retrieves stack trace and isPermanent', () async {
final repo = SyncLogRepositoryImpl(db);
final start = DateTime(2024, 3, 1, 10);
final end = DateTime(2024, 3, 1, 10, 0, 1);
await repo.log(
accountId: 'acc1',
success: false,
errorMessage: 'MissingPluginException',
stackTrace: '#0 MethodChannel._invokeMethod\n#1 main',
isPermanent: true,
protocol: 'imap',
emailsFetched: 0,
emailsSkipped: 0,
mailboxesSynced: 0,
pendingFlushed: 0,
bytesTransferred: 0,
startedAt: start,
finishedAt: end,
);
final entries = await repo.observeSyncLogs('acc1').first;
final latest = entries.firstWhere((e) => e.startedAt == start);
expect(latest.stackTrace, '#0 MethodChannel._invokeMethod\n#1 main');
expect(latest.isPermanent, isTrue);
expect(latest.errorMessage, 'MissingPluginException');
});
test('isPermanent is null for success entries', () async {
final repo = SyncLogRepositoryImpl(db);
final start = DateTime(2024, 4, 1, 10);
final end = DateTime(2024, 4, 1, 10, 0, 3);
await repo.log(
accountId: 'acc1',
success: true,
protocol: 'imap',
emailsFetched: 2,
emailsSkipped: 0,
mailboxesSynced: 1,
pendingFlushed: 0,
bytesTransferred: 0,
startedAt: start,
finishedAt: end,
);
final entries = await repo.observeSyncLogs('acc1').first;
final latest = entries.firstWhere((e) => e.startedAt == start);
expect(latest.isPermanent, isNull);
expect(latest.stackTrace, isNull);
});
}
+65
View File
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
class _FakeAssetBundle extends Fake implements AssetBundle {
final String content;
_FakeAssetBundle(this.content);
@override
Future<String> loadString(String key, {bool cache = true}) async {
if (key == 'assets/changelog.txt') return content;
throw FlutterError('Asset not found: $key');
}
@override
Future<ByteData> load(String key) async {
throw FlutterError('Asset not found: $key');
}
}
void main() {
testWidgets('ChangeLogScreen renders changelog content', (tester) async {
const fakeChangelog =
'* 2026-01-01: Initial release\n* 2026-01-02: Bug fix';
await tester.pumpWidget(
DefaultAssetBundle(
bundle: _FakeAssetBundle(fakeChangelog),
child: const MaterialApp(home: ChangeLogScreen()),
),
);
await tester.pumpAndSettle();
expect(find.text('ChangeLog'), findsOneWidget);
expect(find.textContaining('2026-01-01'), findsOneWidget);
expect(find.textContaining('Initial release'), findsOneWidget);
});
testWidgets('ChangeLogScreen shows error when asset is missing', (
tester,
) async {
await tester.pumpWidget(
DefaultAssetBundle(
bundle: _BadAssetBundle(),
child: const MaterialApp(home: ChangeLogScreen()),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Error loading changelog:'), findsOneWidget);
});
}
class _BadAssetBundle extends Fake implements AssetBundle {
@override
Future<String> loadString(String key, {bool cache = true}) async {
throw FlutterError('Unable to load asset: "$key"');
}
@override
Future<ByteData> load(String key) async {
throw FlutterError('Unable to load asset: "$key"');
}
}
+44
View File
@@ -123,6 +123,50 @@ void main() {
},
);
testWidgets(
'CrashScreen shows git hash as clickable link above stacktrace',
(tester) async {
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
final mock = MockUrlLauncher();
UrlLauncherPlatform.instance = mock;
const exception = 'TestException: git hash test';
final stackTrace = StackTrace.current;
const testHash = 'abc1234';
await tester.pumpWidget(
CrashScreen(
exception: exception,
stackTrace: stackTrace,
gitHash: testHash,
),
);
// Git hash link should be present
final gitLinkFinder = find.textContaining('Git Commit: abc1234');
expect(gitLinkFinder, findsOneWidget);
// Link must appear above the stack trace
final stackTraceFinder = find.text('Stack Trace:');
expect(
tester.getTopLeft(gitLinkFinder).dy,
lessThan(tester.getTopLeft(stackTraceFinder).dy),
);
// Tapping the link should open the Codeberg commit URL
await tester.tap(gitLinkFinder);
await tester.pumpAndSettle();
expect(
mock.launchedUrl,
equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'),
);
},
);
testWidgets(
'CrashScreen used as root widget — buttons work without ScaffoldMessenger crash',
(tester) async {