Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ef4ec3094 | ||
|
|
968db75c69 | ||
|
|
d905cd653f | ||
|
|
e21cde0a3c | ||
|
|
50a6678ec2 | ||
|
|
91083218d4 | ||
|
|
adc4eb6f6d | ||
|
|
05d00bdf09 | ||
|
|
c45775be92 | ||
|
|
47fc534a8d | ||
|
|
a5928c1aa6 | ||
|
|
7f3cd43d6e | ||
|
|
f0f210e5ab |
@@ -17,7 +17,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect Android and Linux changes
|
||||
id: diff
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
data = json.loads(r.read())
|
||||
runs = [
|
||||
r for r in data.get("workflow_runs", [])
|
||||
if r.get("workflow_id") == "deploy.yml" and r.get("status") == "success"
|
||||
if r.get("status") == "success"
|
||||
]
|
||||
print(runs[0].get("commit_sha") or "")
|
||||
except Exception as e:
|
||||
@@ -64,10 +64,17 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Diff the HEAD commit against its parent; fall back to listing HEAD's files
|
||||
# when the parent is unavailable (initial commit, shallow clone).
|
||||
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|
||||
|| git show --name-only --format= HEAD)
|
||||
# Diff from the last successfully deployed commit to catch all changes since
|
||||
# that deploy, not just the most recent commit. Falls back to HEAD~1 when
|
||||
# LAST_DEPLOYED_SHA is unknown or not in local history.
|
||||
if [ -n "$LAST_DEPLOYED_SHA" ] && git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then
|
||||
echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA"
|
||||
CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \
|
||||
|| git show --name-only --format= HEAD)
|
||||
else
|
||||
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|
||||
|| git show --name-only --format= HEAD)
|
||||
fi
|
||||
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED"
|
||||
@@ -204,48 +211,6 @@ jobs:
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
publish-website:
|
||||
name: Publish Website Build History
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-linux, deploy-playstore, deploy-apk]
|
||||
if: |
|
||||
always() &&
|
||||
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success' || needs.deploy-apk.result == 'success')
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
env:
|
||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Generate build history and deploy website
|
||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task publish-website
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
label-deploy-health:
|
||||
name: Update Deploy Health Label
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
name: Monitor Agent Loop
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */2 * * *' # every 2 hours
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
monitor:
|
||||
name: Check Agent Loop Health
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check agent loop heartbeat
|
||||
run: python3 scripts/agent_loop.py monitor
|
||||
@@ -1,6 +1,8 @@
|
||||
name: Update Website
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 * * * *' # every hour on the hour
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
|
||||
@@ -8,46 +8,41 @@ CLI tool `fgj` is available to query issues/PRs/actions.
|
||||
|
||||
## Issue Label Workflow
|
||||
|
||||
We use issues, follow this label state machine:
|
||||
Automation is handled by [agentloop](https://github.com/guettli/agentloop) running every 5 minutes via cron. Add a label to trigger an agent:
|
||||
|
||||
- **State/ToPlan** — Issue needs a plan written by an agent before implementation
|
||||
- **State/Planned** — Plan has been posted as a comment; awaiting human review
|
||||
- **State/Ready** — Issue is approved and ready for implementation
|
||||
- **State/InProgress** — Set while an agent (or human) is actively working
|
||||
- **State/Question** — Agent hit a blocker or needs clarification
|
||||
| Label | Trigger | Outcome |
|
||||
|---|---|---|
|
||||
| `loop/plan` | Planning agent reads the issue and writes an implementation plan as a comment | Issue moves to `loop/plan-done` |
|
||||
| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue moves to `loop/code-done` |
|
||||
|
||||
Full lifecycle:
|
||||
**State machine:**
|
||||
|
||||
```
|
||||
State/ToPlan → State/Planned (automated: agent_loop.py runs a planning agent)
|
||||
State/Planned → State/Ready (manual: human reviews the plan and approves)
|
||||
State/Ready → State/InProgress (automated: agent_loop.py before starting implementation)
|
||||
State/InProgress → closed (automated: after PR is merged and CI passes)
|
||||
any state → State/Question (automated or manual: when blocked)
|
||||
loop/plan → loop/plan-in-progress → loop/plan-done
|
||||
↘ NeedSupervisor (on failure)
|
||||
|
||||
loop/code → loop/code-in-progress → loop/code-done
|
||||
↘ NeedSupervisor (on failure)
|
||||
```
|
||||
|
||||
List open issues ready to pick up:
|
||||
**Rules:**
|
||||
|
||||
- Only issues authored by allowed users are picked up (guettli, guettlibot, guettlibot2, forgejo-actions).
|
||||
- An issue with `NeedSupervisor` needs human attention — investigate, fix, then re-label.
|
||||
- The coding agent opens a PR but does NOT close the issue. A human reviews the PR and closes the issue after merging.
|
||||
- Planning agents only post a comment — they do NOT write code or open PRs.
|
||||
- `loop/*` labels are managed by agentloop — do not set them manually while an agent is active.
|
||||
|
||||
**Typical lifecycle for a new feature:**
|
||||
|
||||
```bash
|
||||
fgj issue list --json --state open | jq '[.[] | select(.labels[].name == "State/Ready")] | .[] | {number, title, html_url}'
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Never start implementation on an issue without `State/Ready`
|
||||
- Planning agents only post a plan comment — they do NOT write code or open PRs
|
||||
- After `State/Planned`, a human must review the plan and manually add `State/Ready`
|
||||
- When working via the agent loop: label transitions are set automatically
|
||||
by `agent_loop.py` — do **not** set them yourself.
|
||||
- When working manually: switch to `State/InProgress` as your **first action**:
|
||||
```bash
|
||||
fgj issue edit <NUMBER> --remove-label "State/Ready" --add-label "State/InProgress"
|
||||
```
|
||||
- If blocked, replace current state label with `State/Question` and leave a comment explaining the blocker
|
||||
- When done and CI is green, close the issue:
|
||||
```bash
|
||||
fgj issue close <NUMBER>
|
||||
```
|
||||
1. Create issue
|
||||
2. Add label loop/plan → agent writes plan as comment
|
||||
3. Review plan, request changes or approve
|
||||
4. Add label loop/code → agent implements + opens PR
|
||||
5. Review PR, merge
|
||||
6. Close issue
|
||||
```
|
||||
|
||||
## Code conventions
|
||||
|
||||
|
||||
+1
-1
@@ -294,7 +294,7 @@ tasks:
|
||||
for attempt in 1 2 3; do
|
||||
run_dagger "$@" && return 0
|
||||
RC=$?
|
||||
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|invalid return status code" "$DAGGER_OUT"; then
|
||||
if [ "$attempt" -lt 3 ] && { grep -qE "connection reset|context canceled|context deadline exceeded|connection refused|invalid return status code" "$DAGGER_OUT" || [ "$RC" -eq 2 ]; }; then
|
||||
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
|
||||
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
|
||||
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
|
||||
|
||||
@@ -4,6 +4,18 @@ This file contains tasks which got implemented.
|
||||
|
||||
Tasks get moved from next.md to done.md
|
||||
|
||||
## Tasks (2026-05-29)
|
||||
|
||||
- **Merge PR #307 — user preferences and configurable navigation (Issue #315)**: Confirmed that
|
||||
all features from PR #307 (issue #299) were already merged into main via separate PRs:
|
||||
- Configurable menu bar position (bottom/top) for mailbox view — merged via #298/#303
|
||||
- Configurable back button position for single mail view — merged via #299/#307 features in #300
|
||||
- Configurable "after mail action" (next message / return to mailbox) — merged via #300/#308
|
||||
- Archive button with `resolveMailboxByRole` helper — merged via #287/#291, #286/#290
|
||||
- User preferences DB schema (v34–v36: `user_preferences` table) — in main
|
||||
- PR #307 and issue #299 closed.
|
||||
- Issue #315 closed.
|
||||
|
||||
## Tasks (2026-05-26)
|
||||
|
||||
- **Renovate Bot (Issue #257)**: Renovate Bot runs daily via Forgejo Actions to keep
|
||||
|
||||
@@ -1 +1 @@
|
||||
const int dbSchemaVersion = 34;
|
||||
const int dbSchemaVersion = 36;
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
enum MenuPosition { bottom, top }
|
||||
|
||||
enum AfterMailViewAction { nextMessage, showMailbox }
|
||||
|
||||
class UserPreferences {
|
||||
const UserPreferences({this.menuPosition = MenuPosition.bottom});
|
||||
const UserPreferences({
|
||||
this.menuPosition = MenuPosition.bottom,
|
||||
this.mailViewButtonPosition = MenuPosition.bottom,
|
||||
this.afterMailViewAction = AfterMailViewAction.nextMessage,
|
||||
});
|
||||
final MenuPosition menuPosition;
|
||||
final MenuPosition mailViewButtonPosition;
|
||||
final AfterMailViewAction afterMailViewAction;
|
||||
}
|
||||
|
||||
@@ -3,4 +3,6 @@ import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||
abstract class UserPreferencesRepository {
|
||||
Stream<UserPreferences> observePreferences();
|
||||
Future<void> updateMenuPosition(MenuPosition position);
|
||||
Future<void> updateMailViewButtonPosition(MenuPosition position);
|
||||
Future<void> updateAfterMailViewAction(AfterMailViewAction action);
|
||||
}
|
||||
|
||||
@@ -313,6 +313,12 @@ class UserPreferences extends Table {
|
||||
IntColumn get id => integer()();
|
||||
// 'bottom' (default) | 'top'
|
||||
TextColumn get menuPosition => text().withDefault(const Constant('bottom'))();
|
||||
// Added in schema v35: 'bottom' (default) | 'top'
|
||||
TextColumn get mailViewButtonPosition =>
|
||||
text().withDefault(const Constant('bottom'))();
|
||||
// Added in schema v36: 'nextMessage' (default) | 'showMailbox'
|
||||
TextColumn get afterMailViewAction =>
|
||||
text().withDefault(const Constant('nextMessage'))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
@@ -593,6 +599,18 @@ class AppDatabase extends _$AppDatabase {
|
||||
if (from < 34) {
|
||||
await m.createTable(userPreferences);
|
||||
}
|
||||
if (from >= 34 && from < 35) {
|
||||
await m.addColumn(
|
||||
userPreferences,
|
||||
userPreferences.mailViewButtonPosition,
|
||||
);
|
||||
}
|
||||
if (from >= 34 && from < 36) {
|
||||
await m.addColumn(
|
||||
userPreferences,
|
||||
userPreferences.afterMailViewAction,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -237,7 +237,12 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
try {
|
||||
await client.selectMailboxByPath(emailRow.mailboxPath);
|
||||
final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])');
|
||||
final msg = fetch.messages.first;
|
||||
final msg = fetch.messages.firstOrNull;
|
||||
if (msg == null) {
|
||||
throw StateError(
|
||||
'IMAP server returned no message for UID ${emailRow.uid}.',
|
||||
);
|
||||
}
|
||||
final textBody = msg.decodeTextPlainPart();
|
||||
final rawHtml = msg.decodeTextHtmlPart();
|
||||
final htmlBody =
|
||||
@@ -2812,7 +2817,12 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
emailRow.uid,
|
||||
'BODY.PEEK[]',
|
||||
);
|
||||
final msg = fetch.messages.first;
|
||||
final msg = fetch.messages.firstOrNull;
|
||||
if (msg == null) {
|
||||
throw StateError(
|
||||
'IMAP server returned no message for UID ${emailRow.uid}.',
|
||||
);
|
||||
}
|
||||
final part = msg.getPart(attachment.fetchPartId) ?? msg;
|
||||
final bytes = part.decodeContentBinary();
|
||||
if (bytes == null) {
|
||||
@@ -2878,7 +2888,13 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
emailRow.uid,
|
||||
'BODY.PEEK[]',
|
||||
);
|
||||
return fetch.messages.first.renderMessage();
|
||||
final msg = fetch.messages.firstOrNull;
|
||||
if (msg == null) {
|
||||
throw StateError(
|
||||
'IMAP server returned no message for UID ${emailRow.uid}.',
|
||||
);
|
||||
}
|
||||
return msg.renderMessage();
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
|
||||
@@ -26,6 +26,28 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateMailViewButtonPosition(pref.MenuPosition position) async {
|
||||
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
||||
UserPreferencesCompanion(
|
||||
id: const Value(_rowId),
|
||||
mailViewButtonPosition: Value(position.name),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateAfterMailViewAction(
|
||||
pref.AfterMailViewAction action,
|
||||
) async {
|
||||
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
||||
UserPreferencesCompanion(
|
||||
id: const Value(_rowId),
|
||||
afterMailViewAction: Value(action.name),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static pref.UserPreferences _rowToModel(UserPreferencesRow? row) {
|
||||
if (row == null) return const pref.UserPreferences();
|
||||
return pref.UserPreferences(
|
||||
@@ -33,6 +55,14 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
|
||||
(e) => e.name == row.menuPosition,
|
||||
orElse: () => pref.MenuPosition.bottom,
|
||||
),
|
||||
mailViewButtonPosition: pref.MenuPosition.values.firstWhere(
|
||||
(e) => e.name == row.mailViewButtonPosition,
|
||||
orElse: () => pref.MenuPosition.bottom,
|
||||
),
|
||||
afterMailViewAction: pref.AfterMailViewAction.values.firstWhere(
|
||||
(e) => e.name == row.afterMailViewAction,
|
||||
orElse: () => pref.AfterMailViewAction.nextMessage,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,15 +120,76 @@ class _AccountTile extends ConsumerWidget {
|
||||
final health = ref.watch(syncHealthProvider(account.id));
|
||||
final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP';
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.account_circle),
|
||||
title: Text(account.displayName),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${account.email}\n$typeLabel'),
|
||||
const SizedBox(height: 4),
|
||||
health.when(
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.account_circle),
|
||||
title: Text(account.displayName),
|
||||
subtitle: Text('${account.email}\n$typeLabel'),
|
||||
isThreeLine: true,
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
status.when(
|
||||
loading: () => const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
data: (_) =>
|
||||
const Icon(Icons.check_circle, color: Colors.green),
|
||||
error: (e, _) => Tooltip(
|
||||
message: e.toString(),
|
||||
child: const Icon(Icons.error_outline, color: Colors.red),
|
||||
),
|
||||
),
|
||||
PopupMenuButton<_AccountAction>(
|
||||
onSelected: (action) => _onAction(context, action),
|
||||
itemBuilder: (_) => [
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.syncLog,
|
||||
child: Text('Sync log'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.verifySync,
|
||||
child: Text('Verify sync health'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.forceSync,
|
||||
child: Text('Force full sync'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.edit,
|
||||
child: Text('Edit'),
|
||||
),
|
||||
if (_sieveSupported(account))
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.emailFiltersRemote,
|
||||
child: Text('Server email filters'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.emailFiltersLocal,
|
||||
child: Text('Local email filters'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.send,
|
||||
child: Text('Send accounts'),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.delete,
|
||||
child: Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(72, 0, 16, 8),
|
||||
child: health.when(
|
||||
data: (h) {
|
||||
if (h == null) return const Text('Sync health: Not verified yet');
|
||||
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
|
||||
@@ -141,7 +202,7 @@ class _AccountTile extends ConsumerWidget {
|
||||
color: h.isHealthy ? Colors.green : Colors.orange,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
Expanded(
|
||||
child: Text(
|
||||
h.isHealthy
|
||||
? 'Healthy'
|
||||
@@ -155,66 +216,8 @@ class _AccountTile extends ConsumerWidget {
|
||||
loading: () => const Text('Sync health: checking...'),
|
||||
error: (e, _) => Text('Sync health error: $e'),
|
||||
),
|
||||
],
|
||||
),
|
||||
isThreeLine: true,
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
status.when(
|
||||
loading: () => const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
data: (_) => const Icon(Icons.check_circle, color: Colors.green),
|
||||
error: (e, _) => Tooltip(
|
||||
message: e.toString(),
|
||||
child: const Icon(Icons.error_outline, color: Colors.red),
|
||||
),
|
||||
),
|
||||
PopupMenuButton<_AccountAction>(
|
||||
onSelected: (action) => _onAction(context, action),
|
||||
itemBuilder: (_) => [
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.syncLog,
|
||||
child: Text('Sync log'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.verifySync,
|
||||
child: Text('Verify sync health'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.forceSync,
|
||||
child: Text('Force full sync'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.edit,
|
||||
child: Text('Edit'),
|
||||
),
|
||||
if (_sieveSupported(account))
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.emailFiltersRemote,
|
||||
child: Text('Server email filters'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.emailFiltersLocal,
|
||||
child: Text('Local email filters'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.send,
|
||||
child: Text('Send accounts'),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.delete,
|
||||
child: Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||
import 'package:sharedinbox/core/utils/format_utils.dart';
|
||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
@@ -76,15 +77,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.forward),
|
||||
tooltip: 'Forward',
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () {
|
||||
unawaited(_forward(context, header, body));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.archive),
|
||||
tooltip: 'Archive',
|
||||
@@ -98,6 +90,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
icon: const Icon(Icons.delete),
|
||||
tooltip: 'Delete',
|
||||
onPressed: () async {
|
||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||
final destPath = await repo.deleteEmail(widget.emailId);
|
||||
|
||||
if (header != null) {
|
||||
@@ -116,28 +109,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
if (context.mounted) context.pop();
|
||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.report_outlined),
|
||||
tooltip: 'Mark as spam',
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () {
|
||||
unawaited(_markAsSpam(context, header));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.drive_file_move_outline),
|
||||
tooltip: 'Move to folder',
|
||||
onPressed: header == null ? null : () => _moveTo(context, header),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.access_time),
|
||||
tooltip: 'Snooze',
|
||||
onPressed: header == null ? null : () => _snooze(context, header),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_isFlagged ? Icons.star : Icons.star_border,
|
||||
@@ -152,10 +126,27 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
itemBuilder: (ctx) => [
|
||||
const PopupMenuItem(
|
||||
value: 'forward',
|
||||
child: Text('Forward'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'move',
|
||||
child: Text('Move to folder'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'snooze',
|
||||
child: Text('Snooze'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'spam',
|
||||
child: Text('Mark as spam'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'mark_unread',
|
||||
child: Text('Mark as unread'),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(
|
||||
value: 'headers',
|
||||
child: Text('Show Mail Headers'),
|
||||
@@ -170,9 +161,18 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
),
|
||||
],
|
||||
onSelected: (value) async {
|
||||
if (value == 'mark_unread') {
|
||||
if (value == 'forward' && header != null) {
|
||||
unawaited(_forward(context, header, body));
|
||||
} else if (value == 'move' && header != null) {
|
||||
unawaited(_moveTo(context, header));
|
||||
} else if (value == 'snooze' && header != null) {
|
||||
unawaited(_snooze(context, header));
|
||||
} else if (value == 'spam' && header != null) {
|
||||
unawaited(_markAsSpam(context, header));
|
||||
} else if (value == 'mark_unread') {
|
||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||
await repo.setFlag(widget.emailId, seen: false);
|
||||
if (context.mounted) context.pop();
|
||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
||||
} else if (value == 'headers' && body != null) {
|
||||
_showHeaders(context, body);
|
||||
} else if (value == 'structure' && body != null) {
|
||||
@@ -252,6 +252,39 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _getNextEmailIdIfNeeded(Email? header) async {
|
||||
if (header == null) return null;
|
||||
final prefs = ref.read(userPreferencesProvider).value;
|
||||
final action =
|
||||
prefs?.afterMailViewAction ?? AfterMailViewAction.nextMessage;
|
||||
if (action != AfterMailViewAction.nextMessage) return null;
|
||||
|
||||
final threads = await ref
|
||||
.read(emailRepositoryProvider)
|
||||
.observeThreads(header.accountId, header.mailboxPath)
|
||||
.first;
|
||||
|
||||
final currentIndex =
|
||||
threads.indexWhere((t) => t.emailIds.contains(widget.emailId));
|
||||
if (currentIndex >= 0 && currentIndex + 1 < threads.length) {
|
||||
return threads[currentIndex + 1].latestEmailId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _navigateTo(BuildContext context, Email? header, String? nextEmailId) {
|
||||
if (!context.mounted) return;
|
||||
if (nextEmailId != null && header != null) {
|
||||
context.go(
|
||||
'/accounts/${header.accountId}'
|
||||
'/mailboxes/${Uri.encodeComponent(header.mailboxPath)}'
|
||||
'/emails/${Uri.encodeComponent(nextEmailId)}',
|
||||
);
|
||||
} else {
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _downloadAndOpen(EmailAttachment att) async {
|
||||
setState(() => _downloading.add(att.filename));
|
||||
try {
|
||||
@@ -403,6 +436,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
}
|
||||
|
||||
Future<void> _archive(BuildContext context, Email header) async {
|
||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||
if (!context.mounted) return;
|
||||
|
||||
final mailbox = await resolveMailboxByRole(
|
||||
context,
|
||||
ref.read(mailboxRepositoryProvider),
|
||||
@@ -432,10 +468,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
),
|
||||
);
|
||||
|
||||
if (context.mounted) context.pop();
|
||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
||||
}
|
||||
|
||||
Future<void> _markAsSpam(BuildContext context, Email header) async {
|
||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||
if (!context.mounted) return;
|
||||
|
||||
final mailbox = await resolveMailboxByRole(
|
||||
context,
|
||||
ref.read(mailboxRepositoryProvider),
|
||||
@@ -465,7 +504,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
),
|
||||
);
|
||||
|
||||
if (context.mounted) context.pop();
|
||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
||||
}
|
||||
|
||||
Future<void> _forward(
|
||||
@@ -490,6 +529,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
}
|
||||
|
||||
Future<void> _moveTo(BuildContext context, Email header) async {
|
||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||
|
||||
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
||||
final mailboxes =
|
||||
await mailboxRepo.observeMailboxes(header.accountId).first;
|
||||
@@ -538,10 +579,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
),
|
||||
);
|
||||
|
||||
if (context.mounted) context.pop();
|
||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
||||
}
|
||||
|
||||
Future<void> _snooze(BuildContext context, Email header) async {
|
||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||
if (!context.mounted) return;
|
||||
|
||||
final until = await showModalBottomSheet<DateTime>(
|
||||
context: context,
|
||||
builder: (ctx) => const SnoozePicker(),
|
||||
@@ -569,7 +613,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
),
|
||||
),
|
||||
);
|
||||
context.pop();
|
||||
_navigateTo(context, header, nextEmailId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:intl/intl.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
||||
@@ -28,9 +29,16 @@ class ThreadDetailScreen extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final repo = ref.watch(emailRepositoryProvider);
|
||||
final prefs =
|
||||
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
|
||||
final buttonAtBottom = prefs.mailViewButtonPosition == MenuPosition.bottom;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Thread')),
|
||||
appBar: AppBar(
|
||||
title: const Text('Thread'),
|
||||
automaticallyImplyLeading: !buttonAtBottom,
|
||||
),
|
||||
bottomNavigationBar: buttonAtBottom ? _buildBackButtonBar(context) : null,
|
||||
body: StreamBuilder<List<Email>>(
|
||||
stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId),
|
||||
builder: (context, snapshot) {
|
||||
@@ -60,6 +68,20 @@ class ThreadDetailScreen extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBackButtonBar(BuildContext context) {
|
||||
return BottomAppBar(
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: 'Back',
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmailMessageCard extends ConsumerStatefulWidget {
|
||||
@@ -141,6 +163,17 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
FutureBuilder<EmailBody>(
|
||||
future: _bodyFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Failed to load email: ${snapshot.error}',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
|
||||
@@ -59,6 +59,84 @@ class UserPreferencesScreen extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
title: Text(
|
||||
'Single mail view button position',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Where the back button is shown in the single mail view.',
|
||||
),
|
||||
),
|
||||
RadioGroup<MenuPosition>(
|
||||
groupValue: prefs.mailViewButtonPosition,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
unawaited(
|
||||
ref
|
||||
.read(userPreferencesRepositoryProvider)
|
||||
.updateMailViewButtonPosition(value),
|
||||
);
|
||||
},
|
||||
child: const Column(
|
||||
children: [
|
||||
RadioListTile<MenuPosition>(
|
||||
title: Text('Bottom (default)'),
|
||||
subtitle: Text(
|
||||
'Show the back button at the bottom of the screen.',
|
||||
),
|
||||
value: MenuPosition.bottom,
|
||||
),
|
||||
RadioListTile<MenuPosition>(
|
||||
title: Text('Top'),
|
||||
subtitle: Text(
|
||||
'Show the back button in the top bar.',
|
||||
),
|
||||
value: MenuPosition.top,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
title: Text(
|
||||
'After mail action',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
subtitle: const Text(
|
||||
'What to show after deleting, archiving, or otherwise handling a message.',
|
||||
),
|
||||
),
|
||||
RadioGroup<AfterMailViewAction>(
|
||||
groupValue: prefs.afterMailViewAction,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
unawaited(
|
||||
ref
|
||||
.read(userPreferencesRepositoryProvider)
|
||||
.updateAfterMailViewAction(value),
|
||||
);
|
||||
},
|
||||
child: const Column(
|
||||
children: [
|
||||
RadioListTile<AfterMailViewAction>(
|
||||
title: Text('Next message (default)'),
|
||||
subtitle: Text(
|
||||
'Show the next message in the mailbox.',
|
||||
),
|
||||
value: AfterMailViewAction.nextMessage,
|
||||
),
|
||||
RadioListTile<AfterMailViewAction>(
|
||||
title: Text('Return to mailbox'),
|
||||
subtitle: Text(
|
||||
'Return to the message list.',
|
||||
),
|
||||
value: AfterMailViewAction.showMailbox,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
+8
-8
@@ -659,10 +659,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
version: "1.18.0"
|
||||
mime:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1088,26 +1088,26 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: test
|
||||
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
|
||||
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.30.0"
|
||||
version: "1.31.0"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
version: "0.7.11"
|
||||
test_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_core
|
||||
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
|
||||
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.16"
|
||||
version: "0.6.17"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
],
|
||||
"labels": ["dependencies"],
|
||||
"github-actions": {
|
||||
"fileMatch": ["^\\.forgejo/workflows/[^/]+\\.ya?ml$"]
|
||||
"enabled": false
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,12 @@ for attempt in $(seq 1 $MAX_PROBE_ATTEMPTS); do
|
||||
fi
|
||||
if [ "$attempt" -eq "$MAX_PROBE_ATTEMPTS" ]; then
|
||||
echo "Warning: No Dagger server responded on $host:$port after $MAX_PROBE_ATTEMPTS attempts"
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "Error: Remote Dagger engine is unavailable AND local Docker daemon is not running."
|
||||
echo "Cannot proceed. Ensure either the remote server at $host:$port is accessible"
|
||||
echo "or that Docker is running locally (check: sudo systemctl start docker)."
|
||||
exit 1
|
||||
fi
|
||||
echo "Remote engine unavailable — CI will use the local Dagger engine."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -1,938 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for agent_loop.py."""
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
import agent_loop
|
||||
|
||||
|
||||
class TestUrlHelpers(unittest.TestCase):
|
||||
def test_issue_url(self):
|
||||
url = agent_loop._issue_url(128)
|
||||
self.assertEqual(url, "https://codeberg.org/guettli/sharedinbox/issues/128")
|
||||
|
||||
def test_ci_run_url(self):
|
||||
url = agent_loop._ci_run_url(4145144)
|
||||
self.assertEqual(url, "https://codeberg.org/guettli/sharedinbox/actions/runs/4145144")
|
||||
|
||||
|
||||
class TestStateFile(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".json")
|
||||
self._tmp.close()
|
||||
self._orig = agent_loop.STATE_FILE
|
||||
agent_loop.STATE_FILE = Path(self._tmp.name)
|
||||
Path(self._tmp.name).unlink() # Start with no state file.
|
||||
|
||||
def tearDown(self):
|
||||
agent_loop.STATE_FILE = self._orig
|
||||
Path(self._tmp.name).unlink(missing_ok=True)
|
||||
|
||||
def test_write_state_stores_pid(self):
|
||||
agent_loop._write_state(12345, 91, "issue")
|
||||
data = json.loads(Path(self._tmp.name).read_text())
|
||||
self.assertEqual(data["pid"], 12345)
|
||||
self.assertNotIn("tmux_session", data)
|
||||
|
||||
def test_write_state_stores_issue_and_kind(self):
|
||||
agent_loop._write_state(99, 7, "ci-fix")
|
||||
data = json.loads(Path(self._tmp.name).read_text())
|
||||
self.assertEqual(data["issue"], 7)
|
||||
self.assertEqual(data["type"], "ci-fix")
|
||||
self.assertIn("started_at", data)
|
||||
|
||||
def test_read_state_returns_none_when_missing(self):
|
||||
self.assertIsNone(agent_loop._read_state())
|
||||
|
||||
def test_read_and_write_roundtrip(self):
|
||||
agent_loop._write_state(42, 10, "issue")
|
||||
state = agent_loop._read_state()
|
||||
self.assertIsNotNone(state)
|
||||
self.assertEqual(state["pid"], 42)
|
||||
self.assertEqual(state["issue"], 10)
|
||||
|
||||
def test_clear_state_removes_file(self):
|
||||
agent_loop._write_state(1, None, "ci-fix")
|
||||
agent_loop._clear_state()
|
||||
self.assertIsNone(agent_loop._read_state())
|
||||
|
||||
def test_write_state_stores_issue_title(self):
|
||||
agent_loop._write_state(42, 10, "issue", "My Test Issue")
|
||||
data = json.loads(Path(self._tmp.name).read_text())
|
||||
self.assertEqual(data["issue_title"], "My Test Issue")
|
||||
|
||||
def test_write_state_omits_issue_title_when_none(self):
|
||||
agent_loop._write_state(42, None, "ci-fix")
|
||||
data = json.loads(Path(self._tmp.name).read_text())
|
||||
self.assertNotIn("issue_title", data)
|
||||
|
||||
|
||||
class TestAgentAlive(unittest.TestCase):
|
||||
def test_own_pid_is_alive(self):
|
||||
self.assertTrue(agent_loop._agent_alive({"pid": os.getpid()}))
|
||||
|
||||
def test_nonexistent_pid_is_dead(self):
|
||||
self.assertFalse(agent_loop._agent_alive({"pid": 999999999}))
|
||||
|
||||
def test_missing_pid_returns_false(self):
|
||||
self.assertFalse(agent_loop._agent_alive({}))
|
||||
self.assertFalse(agent_loop._agent_alive({"pid": None}))
|
||||
|
||||
|
||||
class TestIsClaudeProcess(unittest.TestCase):
|
||||
def test_returns_true_for_claude_comm(self):
|
||||
with patch.object(agent_loop.Path, "read_text", return_value="claude\n"):
|
||||
self.assertTrue(agent_loop._is_claude_process(1234))
|
||||
|
||||
def test_returns_true_for_node_comm(self):
|
||||
with patch.object(agent_loop.Path, "read_text", return_value="node\n"):
|
||||
self.assertTrue(agent_loop._is_claude_process(1234))
|
||||
|
||||
def test_returns_false_for_other_process(self):
|
||||
with patch.object(agent_loop.Path, "read_text", return_value="bash\n"):
|
||||
self.assertFalse(agent_loop._is_claude_process(1234))
|
||||
|
||||
def test_returns_false_when_proc_missing(self):
|
||||
with patch.object(agent_loop.Path, "read_text", side_effect=OSError):
|
||||
self.assertFalse(agent_loop._is_claude_process(1234))
|
||||
|
||||
|
||||
class TestKillAgent(unittest.TestCase):
|
||||
def test_kill_sends_sigkill(self):
|
||||
with patch("agent_loop._is_claude_process", return_value=True):
|
||||
with patch("agent_loop.os.kill") as mock_kill:
|
||||
agent_loop._kill_agent({"pid": 1234})
|
||||
mock_kill.assert_called_once_with(1234, 9)
|
||||
|
||||
def test_kill_ignores_missing_process(self):
|
||||
with patch("agent_loop._is_claude_process", return_value=True):
|
||||
with patch("agent_loop.os.kill", side_effect=ProcessLookupError):
|
||||
agent_loop._kill_agent({"pid": 1234}) # Should not raise.
|
||||
|
||||
def test_kill_noop_when_no_pid(self):
|
||||
with patch("agent_loop.os.kill") as mock_kill:
|
||||
agent_loop._kill_agent({})
|
||||
mock_kill.assert_not_called()
|
||||
|
||||
def test_kill_skips_recycled_pid(self):
|
||||
with patch("agent_loop._is_claude_process", return_value=False):
|
||||
with patch("agent_loop.os.kill") as mock_kill:
|
||||
agent_loop._kill_agent({"pid": 1234})
|
||||
mock_kill.assert_not_called()
|
||||
|
||||
|
||||
class TestStartAgent(unittest.TestCase):
|
||||
def _make_mock_proc(self, pid=42):
|
||||
proc = MagicMock()
|
||||
proc.pid = pid
|
||||
proc.stdin = io.BytesIO()
|
||||
return proc
|
||||
|
||||
def test_start_agent_returns_pid(self):
|
||||
mock_proc = self._make_mock_proc(pid=42)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with patch("agent_loop.subprocess.Popen", return_value=mock_proc):
|
||||
with patch.object(agent_loop.Path, "home", return_value=Path(tmpdir)):
|
||||
result = agent_loop._start_agent("do something", "issue-99")
|
||||
self.assertEqual(result, 42)
|
||||
|
||||
def test_start_agent_uses_popen_not_tmux(self):
|
||||
mock_proc = self._make_mock_proc(pid=7)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with patch("agent_loop.subprocess.Popen", return_value=mock_proc) as mock_popen:
|
||||
with patch("agent_loop.subprocess.run") as mock_run:
|
||||
with patch.object(agent_loop.Path, "home", return_value=Path(tmpdir)):
|
||||
agent_loop._start_agent("prompt", "ci-fix")
|
||||
mock_popen.assert_called_once()
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_start_agent_passes_session_name_to_claude(self):
|
||||
mock_proc = self._make_mock_proc(pid=7)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with patch("agent_loop.subprocess.Popen", return_value=mock_proc) as mock_popen:
|
||||
with patch.object(agent_loop.Path, "home", return_value=Path(tmpdir)):
|
||||
agent_loop._start_agent("prompt", "issue-55")
|
||||
cmd = mock_popen.call_args[0][0]
|
||||
self.assertIn("issue-55", cmd)
|
||||
self.assertIn("claude", cmd[0])
|
||||
|
||||
def test_start_agent_uses_start_new_session(self):
|
||||
mock_proc = self._make_mock_proc(pid=7)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with patch("agent_loop.subprocess.Popen", return_value=mock_proc) as mock_popen:
|
||||
with patch.object(agent_loop.Path, "home", return_value=Path(tmpdir)):
|
||||
agent_loop._start_agent("prompt", "issue-55")
|
||||
kwargs = mock_popen.call_args[1]
|
||||
self.assertTrue(kwargs.get("start_new_session"))
|
||||
|
||||
|
||||
class TestMain(unittest.TestCase):
|
||||
"""Tests for the main() flow."""
|
||||
|
||||
def _make_mock_proc(self, pid=42):
|
||||
proc = MagicMock()
|
||||
proc.pid = pid
|
||||
proc.stdin = io.BytesIO()
|
||||
return proc
|
||||
|
||||
def _make_issue(self, number=10, title="Do something"):
|
||||
return {"number": number, "title": title, "body": "", "labels": []}
|
||||
|
||||
def test_sets_in_progress_before_starting_agent(self):
|
||||
"""_set_labels(InProgress) must be called before _start_agent."""
|
||||
call_order = []
|
||||
mock_proc = self._make_mock_proc(pid=55)
|
||||
|
||||
def fake_set_labels(issue, add, remove):
|
||||
call_order.append(("set_labels", add, remove))
|
||||
|
||||
def fake_start_agent(prompt, session_name):
|
||||
call_order.append(("start_agent", session_name))
|
||||
return 55
|
||||
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[self._make_issue(10)]), \
|
||||
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
|
||||
patch("agent_loop._start_agent", side_effect=fake_start_agent), \
|
||||
patch("agent_loop._write_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
labels_idx = next(
|
||||
i for i, c in enumerate(call_order) if c[0] == "set_labels"
|
||||
)
|
||||
agent_idx = next(
|
||||
i for i, c in enumerate(call_order) if c[0] == "start_agent"
|
||||
)
|
||||
self.assertLess(labels_idx, agent_idx,
|
||||
"_set_labels must be called before _start_agent")
|
||||
|
||||
def test_sets_in_progress_label_and_removes_ready(self):
|
||||
"""The InProgress label is added and the Ready label is removed."""
|
||||
captured = {}
|
||||
|
||||
def fake_set_labels(issue, add, remove):
|
||||
captured["add"] = add
|
||||
captured["remove"] = remove
|
||||
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[self._make_issue(7)]), \
|
||||
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
|
||||
patch("agent_loop._start_agent", return_value=99), \
|
||||
patch("agent_loop._write_state"):
|
||||
agent_loop._run_loop()
|
||||
|
||||
self.assertIn(agent_loop.LABEL_IN_PROGRESS, captured.get("add", []))
|
||||
self.assertIn(agent_loop.LABEL_READY, captured.get("remove", []))
|
||||
|
||||
def test_no_ready_issues_does_nothing(self):
|
||||
"""main() exits cleanly with 0 when there are no ready issues."""
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[]), \
|
||||
patch("agent_loop._set_labels") as mock_labels, \
|
||||
patch("agent_loop._start_agent") as mock_start:
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_labels.assert_not_called()
|
||||
mock_start.assert_not_called()
|
||||
|
||||
def test_prompt_does_not_tell_agent_to_close_issue(self):
|
||||
"""Agents must not close issues; the loop handles closing after CI passes."""
|
||||
captured_prompt = {}
|
||||
|
||||
def fake_start_agent(prompt, session_name):
|
||||
captured_prompt["prompt"] = prompt
|
||||
return 77
|
||||
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[self._make_issue(42)]), \
|
||||
patch("agent_loop._set_labels"), \
|
||||
patch("agent_loop._start_agent", side_effect=fake_start_agent), \
|
||||
patch("agent_loop._write_state"):
|
||||
agent_loop._run_loop()
|
||||
|
||||
prompt = captured_prompt.get("prompt", "")
|
||||
# "do NOT close the issue" (blocker instruction) is fine; what must be
|
||||
# absent is any affirmative instruction to close on completion.
|
||||
self.assertNotIn("close the issue and stop", prompt.lower())
|
||||
|
||||
|
||||
class TestPendingCi(unittest.TestCase):
|
||||
"""Tests for the pending-CI state: issue closed only after CI passes."""
|
||||
|
||||
def _dead_state(self, issue: int, kind: str = "issue") -> dict:
|
||||
return {
|
||||
"pid": 999999999, # non-existent PID
|
||||
"issue": issue,
|
||||
"started_at": "2026-01-01T00:00:00+00:00",
|
||||
"type": kind,
|
||||
}
|
||||
|
||||
def _open_pr(self, branch: str = "issue-10-fix") -> dict:
|
||||
return {"number": 5, "head": {"ref": branch}, "created_at": "2026-01-01T00:00:00+00:00"}
|
||||
|
||||
def _find_pr_open(self, branch, state="open"):
|
||||
if state == "open":
|
||||
return self._open_pr(branch)
|
||||
return None
|
||||
|
||||
def test_closes_issue_when_ci_passes_after_agent_finishes(self):
|
||||
"""After issue agent finishes, loop merges the PR and closes the issue once CI is green."""
|
||||
# First call: PR found open. Second call (post-merge verification): PR closed.
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._merge_pr") as mock_merge, \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_merge.assert_called_once_with(5)
|
||||
mock_close.assert_called_once_with(10)
|
||||
|
||||
def test_ci_passed_output_includes_ci_run_url(self):
|
||||
"""'CI passed' line includes the CI run URL when a run is available."""
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 4145144, "status": "success"}), \
|
||||
patch("agent_loop._merge_pr"), \
|
||||
patch("agent_loop._close_issue"), \
|
||||
patch("agent_loop._clear_state"), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
output = buf.getvalue()
|
||||
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144", output)
|
||||
self.assertIn("https://codeberg.org/guettli/sharedinbox/issues/10", output)
|
||||
|
||||
def test_already_merged_pr_closes_issue_without_ci_url(self):
|
||||
"""When the PR was already merged, the issue is closed and no CI run URL appears."""
|
||||
def find_pr(branch, state="open"):
|
||||
if state == "closed":
|
||||
return {"number": 5, "merged": True}
|
||||
return None
|
||||
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=find_pr), \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._clear_state"), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
result = agent_loop._run_loop()
|
||||
output = buf.getvalue()
|
||||
self.assertEqual(result, 0)
|
||||
mock_close.assert_called_once_with(10)
|
||||
self.assertIn("already merged", output)
|
||||
self.assertNotIn("/actions/runs/", output)
|
||||
|
||||
def test_no_pr_found_sets_question_label(self):
|
||||
"""When no open or merged PR exists for the pending branch, set State/Question."""
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch", return_value=None), \
|
||||
patch("agent_loop._set_labels") as mock_labels, \
|
||||
patch("agent_loop._comment_issue") as mock_comment, \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_close.assert_not_called()
|
||||
mock_labels.assert_called_once_with(
|
||||
10,
|
||||
add=[agent_loop.LABEL_QUESTION],
|
||||
remove=[agent_loop.LABEL_IN_PROGRESS],
|
||||
)
|
||||
mock_comment.assert_called_once()
|
||||
self.assertIn("issue-10-fix", mock_comment.call_args[0][1])
|
||||
|
||||
def test_does_not_close_issue_when_ci_fails(self):
|
||||
"""After issue agent finishes, loop must NOT close the issue if CI failed on PR branch."""
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "failure"}), \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._start_agent", return_value=55), \
|
||||
patch("agent_loop._write_state"), \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_close.assert_not_called()
|
||||
|
||||
def test_saves_pending_ci_state_while_ci_running(self):
|
||||
"""When CI is still running on PR branch after agent finishes, pending issue is preserved."""
|
||||
written = {}
|
||||
|
||||
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
|
||||
written["pid"] = pid
|
||||
written["issue"] = issue
|
||||
written["kind"] = kind
|
||||
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "running"}), \
|
||||
patch("agent_loop._write_state", side_effect=fake_write_state), \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(written.get("issue"), 10)
|
||||
self.assertEqual(written.get("kind"), "pending-ci")
|
||||
self.assertIsNone(written.get("pid"))
|
||||
|
||||
def test_ci_fix_preserves_pending_issue_in_state(self):
|
||||
"""When CI fails on PR branch after agent finishes, ci-fix state includes the pending issue."""
|
||||
written = {}
|
||||
|
||||
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
|
||||
written["pid"] = pid
|
||||
written["issue"] = issue
|
||||
written["kind"] = kind
|
||||
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "failure"}), \
|
||||
patch("agent_loop._start_agent", return_value=55), \
|
||||
patch("agent_loop._write_state", side_effect=fake_write_state), \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(written.get("issue"), 10)
|
||||
self.assertEqual(written.get("kind"), "ci-fix")
|
||||
|
||||
def test_closes_issue_after_ci_fix_and_ci_passes(self):
|
||||
"""After ci-fix agent finishes and CI passes on PR branch, the pending issue is closed."""
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10, "ci-fix")), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._merge_pr") as mock_merge, \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_merge.assert_called_once_with(5)
|
||||
mock_close.assert_called_once_with(10)
|
||||
|
||||
def test_no_pending_issue_ci_fix_without_issue(self):
|
||||
"""ci-fix for a manual push (no pending issue) does not try to close anything."""
|
||||
with patch("agent_loop._read_state", return_value={
|
||||
"pid": 999999999, "issue": None, "started_at": "2026-01-01T00:00:00+00:00",
|
||||
"type": "ci-fix",
|
||||
}), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._ready_issues", return_value=[]), \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_close.assert_not_called()
|
||||
|
||||
|
||||
class TestOutputFormat(unittest.TestCase):
|
||||
"""Verify output format: no [agent_loop] prefix, URLs in output."""
|
||||
|
||||
def test_output_starts_with_header(self):
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[]), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
first_line = buf.getvalue().splitlines()[0]
|
||||
self.assertTrue(first_line.startswith("---------------------- Starting "),
|
||||
f"Unexpected first line: {first_line!r}")
|
||||
|
||||
def test_no_agent_loop_prefix_in_output(self):
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[]), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
self.assertNotIn("[agent_loop]", buf.getvalue())
|
||||
|
||||
def test_ci_run_output_contains_url(self):
|
||||
run = {"id": 4145144, "status": "running"}
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=run), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144",
|
||||
buf.getvalue())
|
||||
|
||||
def test_issue_output_contains_url_and_title(self):
|
||||
issue = {"number": 128, "title": "Fix something", "body": "", "labels": []}
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[issue]), \
|
||||
patch("agent_loop._set_labels"), \
|
||||
patch("agent_loop._start_agent", return_value=99), \
|
||||
patch("agent_loop._write_state"), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
output = buf.getvalue()
|
||||
self.assertIn("https://codeberg.org/guettli/sharedinbox/issues/128", output)
|
||||
self.assertIn("Fix something", output)
|
||||
|
||||
|
||||
class TestLatestMainCiRun(unittest.TestCase):
|
||||
"""_latest_main_ci_run() must return only ci.yml push-to-main runs."""
|
||||
|
||||
def _ci_run(self, run_id, status="success"):
|
||||
return {"event": "push", "prettyref": "main", "workflow_id": "ci.yml",
|
||||
"status": status, "id": run_id}
|
||||
|
||||
def _deploy_run(self, run_id, status="success"):
|
||||
return {"event": "push", "prettyref": "main", "workflow_id": "deploy.yml",
|
||||
"status": status, "id": run_id}
|
||||
|
||||
def test_skips_deploy_run_returns_ci_run(self):
|
||||
# Forgejo reports deploy.yml schedule runs as event=push/prettyref=main;
|
||||
# must be excluded by workflow_id filter.
|
||||
runs = [self._deploy_run(1), self._ci_run(2)]
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||
result = agent_loop._latest_main_ci_run()
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["id"], 2)
|
||||
|
||||
def test_returns_none_when_only_deploy_runs_exist(self):
|
||||
runs = [self._deploy_run(1)]
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||
result = agent_loop._latest_main_ci_run()
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_none_when_only_schedule_runs_exist(self):
|
||||
runs = [{"event": "schedule", "prettyref": "main", "workflow_id": "deploy.yml",
|
||||
"status": "success", "id": 1}]
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||
result = agent_loop._latest_main_ci_run()
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_ci_push_to_main_run(self):
|
||||
runs = [self._ci_run(42, status="running")]
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||
result = agent_loop._latest_main_ci_run()
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["id"], 42)
|
||||
|
||||
|
||||
class TestLatestCiRunForBranch(unittest.TestCase):
|
||||
"""Tests for _latest_ci_run_for_branch — Forgejo API field mapping."""
|
||||
|
||||
def _make_pr_run(self, branch: str, status: str = "success") -> dict:
|
||||
payload = json.dumps({"pull_request": {"head": {"ref": branch}}})
|
||||
return {"event": "pull_request", "event_payload": payload, "status": status, "id": 1}
|
||||
|
||||
def _make_push_run(self, prettyref: str, status: str = "success") -> dict:
|
||||
return {"event": "push", "prettyref": prettyref, "status": status, "id": 2}
|
||||
|
||||
def _mock_tea_runs(self, runs):
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}) as m:
|
||||
yield m
|
||||
|
||||
def test_pr_event_matches_via_event_payload(self):
|
||||
run = self._make_pr_run("issue-166-fix")
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
|
||||
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["id"], 1)
|
||||
|
||||
def test_pr_event_does_not_match_wrong_branch(self):
|
||||
run = self._make_pr_run("issue-99-fix")
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
|
||||
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_push_event_matches_via_prettyref(self):
|
||||
run = self._make_push_run("issue-166-fix")
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
|
||||
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["id"], 2)
|
||||
|
||||
def test_push_event_prettyref_pr_number_does_not_match_branch(self):
|
||||
# Forgejo sets prettyref="#169" for PR runs — must not match branch name.
|
||||
run = {"event": "push", "prettyref": "#169", "status": "success", "id": 3}
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
|
||||
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_head_branch_field_absent_still_works(self):
|
||||
# Regression: the old code used run.get("head_branch") which is absent in Forgejo.
|
||||
run = self._make_pr_run("issue-166-fix")
|
||||
self.assertNotIn("head_branch", run)
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
|
||||
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||
self.assertIsNotNone(result)
|
||||
|
||||
def test_returns_none_when_no_runs(self):
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": []}):
|
||||
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_first_matching_run(self):
|
||||
runs = [
|
||||
self._make_pr_run("issue-166-fix", status="success"),
|
||||
self._make_pr_run("issue-166-fix", status="failure"),
|
||||
]
|
||||
runs[0]["id"] = 10
|
||||
runs[1]["id"] = 11
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||
self.assertEqual(result["id"], 10)
|
||||
|
||||
|
||||
class TestFindSessionUuid(unittest.TestCase):
|
||||
"""Tests for _find_session_uuid()."""
|
||||
|
||||
def _write_jsonl(self, directory: Path, filename: str, entries: list) -> Path:
|
||||
path = directory / filename
|
||||
with path.open("w") as fh:
|
||||
for entry in entries:
|
||||
fh.write(json.dumps(entry) + "\n")
|
||||
return path
|
||||
|
||||
def test_returns_uuid_for_matching_session_name(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
projects_dir = Path(tmpdir)
|
||||
self._write_jsonl(projects_dir, "abc123.jsonl", [
|
||||
{"type": "agent-name", "agentName": "issue-91", "sessionId": "uuid-abc-123"},
|
||||
])
|
||||
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
|
||||
try:
|
||||
result = agent_loop._find_session_uuid("issue-91")
|
||||
finally:
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
||||
self.assertEqual(result, "uuid-abc-123")
|
||||
|
||||
def test_returns_none_when_name_does_not_match(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
projects_dir = Path(tmpdir)
|
||||
self._write_jsonl(projects_dir, "abc123.jsonl", [
|
||||
{"type": "agent-name", "agentName": "issue-99", "sessionId": "uuid-abc-123"},
|
||||
])
|
||||
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
|
||||
try:
|
||||
result = agent_loop._find_session_uuid("issue-91")
|
||||
finally:
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_none_when_directory_missing(self):
|
||||
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = Path("/nonexistent/path/that/does/not/exist")
|
||||
try:
|
||||
result = agent_loop._find_session_uuid("issue-91")
|
||||
finally:
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_none_when_no_agent_name_entry(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
projects_dir = Path(tmpdir)
|
||||
self._write_jsonl(projects_dir, "abc123.jsonl", [
|
||||
{"type": "message", "content": "hello"},
|
||||
])
|
||||
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
|
||||
try:
|
||||
result = agent_loop._find_session_uuid("issue-91")
|
||||
finally:
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_scans_multiple_files_to_find_match(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
projects_dir = Path(tmpdir)
|
||||
self._write_jsonl(projects_dir, "aaa.jsonl", [
|
||||
{"type": "agent-name", "agentName": "issue-10", "sessionId": "uuid-10"},
|
||||
])
|
||||
self._write_jsonl(projects_dir, "bbb.jsonl", [
|
||||
{"type": "agent-name", "agentName": "issue-91", "sessionId": "uuid-91"},
|
||||
])
|
||||
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
|
||||
try:
|
||||
result = agent_loop._find_session_uuid("issue-91")
|
||||
finally:
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
||||
self.assertEqual(result, "uuid-91")
|
||||
|
||||
|
||||
class TestRunLoopResumeCommand(unittest.TestCase):
|
||||
"""Tests that _run_loop() shows a UUID-based resume command when agent is running."""
|
||||
|
||||
def _alive_state(self, session_name="issue-91"):
|
||||
return {
|
||||
"pid": os.getpid(), # own PID is always alive
|
||||
"issue": 91,
|
||||
"started_at": "2026-05-23T12:00:00+00:00",
|
||||
"type": "issue",
|
||||
"session_name": session_name,
|
||||
}
|
||||
|
||||
def test_resume_shows_uuid_when_found(self):
|
||||
buf = io.StringIO()
|
||||
fake_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
with patch("agent_loop._read_state", return_value=self._alive_state()), \
|
||||
patch("agent_loop._agent_alive", return_value=True), \
|
||||
patch("agent_loop._agent_age_seconds", return_value=600), \
|
||||
patch("agent_loop._find_session_uuid", return_value=fake_uuid), \
|
||||
patch("agent_loop._git_summary", return_value=""), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
output = buf.getvalue()
|
||||
self.assertIn(f"claude --resume {fake_uuid}", output)
|
||||
|
||||
def test_resume_shows_list_hint_when_uuid_not_found(self):
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=self._alive_state()), \
|
||||
patch("agent_loop._agent_alive", return_value=True), \
|
||||
patch("agent_loop._agent_age_seconds", return_value=600), \
|
||||
patch("agent_loop._find_session_uuid", return_value=None), \
|
||||
patch("agent_loop._git_summary", return_value=""), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
output = buf.getvalue()
|
||||
self.assertIn("scripts/agent_loop.py list", output)
|
||||
# Must NOT show the session name as a valid resume argument.
|
||||
self.assertNotIn("claude --resume issue-91", output)
|
||||
|
||||
def test_resume_not_shown_when_no_session_name(self):
|
||||
state = self._alive_state()
|
||||
del state["session_name"]
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=state), \
|
||||
patch("agent_loop._agent_alive", return_value=True), \
|
||||
patch("agent_loop._agent_age_seconds", return_value=600), \
|
||||
patch("agent_loop._find_session_uuid", return_value=None), \
|
||||
patch("agent_loop._git_summary", return_value=""), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
output = buf.getvalue()
|
||||
self.assertNotIn("Resume:", output)
|
||||
|
||||
|
||||
|
||||
class TestCatchupSkipsQuestionIssues(unittest.TestCase):
|
||||
"""Catch-up must not retry merging a PR whose issue is already State/Question."""
|
||||
|
||||
def _make_pr(self, pr_number=50, branch="issue-10-fix"):
|
||||
return {"number": pr_number, "head": {"ref": branch}}
|
||||
|
||||
def test_skips_merge_when_issue_has_question_label(self):
|
||||
pr = self._make_pr()
|
||||
ci_run = {"id": 999, "status": "success"}
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[pr]), \
|
||||
patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \
|
||||
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
|
||||
patch("agent_loop._merge_pr") as mock_merge, \
|
||||
patch("agent_loop._comment_issue") as mock_comment, \
|
||||
patch("agent_loop._set_labels") as mock_labels, \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[]):
|
||||
result = agent_loop._run_loop()
|
||||
self.assertEqual(result, 0)
|
||||
mock_merge.assert_not_called()
|
||||
mock_comment.assert_not_called()
|
||||
mock_labels.assert_not_called()
|
||||
|
||||
def test_proceeds_with_merge_when_issue_lacks_question_label(self):
|
||||
pr = self._make_pr()
|
||||
ci_run = {"id": 999, "status": "success"}
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[pr]), \
|
||||
patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \
|
||||
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_IN_PROGRESS]), \
|
||||
patch("agent_loop._merge_pr") as mock_merge, \
|
||||
patch("agent_loop._find_pr_for_branch", return_value=None), \
|
||||
patch("agent_loop._close_issue"):
|
||||
result = agent_loop._run_loop()
|
||||
self.assertEqual(result, 0)
|
||||
mock_merge.assert_called_once_with(50)
|
||||
|
||||
|
||||
class TestMergeFailsOpen(unittest.TestCase):
|
||||
"""Tests for auto-resolution when a PR is still open after the merge command."""
|
||||
|
||||
def _dead_state(self, issue: int, kind: str = "issue") -> dict:
|
||||
return {
|
||||
"pid": 999999999,
|
||||
"issue": issue,
|
||||
"started_at": "2026-01-01T00:00:00+00:00",
|
||||
"type": kind,
|
||||
}
|
||||
|
||||
def _open_pr(self, branch: str = "issue-10-fix") -> dict:
|
||||
return {"number": 5, "head": {"ref": branch}, "created_at": "2026-01-01T00:00:00+00:00"}
|
||||
|
||||
def test_merge_fails_open_with_conflicts_spawns_rebase_agent(self):
|
||||
"""mergeable=false → rebase agent spawned, state written as pending-ci."""
|
||||
written_state = {}
|
||||
|
||||
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
|
||||
written_state["pid"] = pid
|
||||
written_state["issue"] = issue
|
||||
written_state["kind"] = kind
|
||||
written_state["session_name"] = session_name
|
||||
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), self._open_pr()]), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._merge_pr"), \
|
||||
patch("agent_loop._tea_get", return_value={"mergeable": False}), \
|
||||
patch("agent_loop._start_agent", return_value=77) as mock_start, \
|
||||
patch("agent_loop._write_state", side_effect=fake_write_state), \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_start.assert_called_once()
|
||||
prompt = mock_start.call_args[0][0]
|
||||
self.assertIn("Rebase branch", prompt)
|
||||
self.assertIn("issue-10-fix", prompt)
|
||||
self.assertEqual(written_state.get("kind"), "pending-ci")
|
||||
self.assertEqual(written_state.get("issue"), 10)
|
||||
|
||||
def test_merge_fails_open_no_conflicts_retries_and_succeeds(self):
|
||||
"""mergeable=true, second attempt succeeds → issue closed."""
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch",
|
||||
side_effect=[self._open_pr(), self._open_pr(), None]), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._merge_pr"), \
|
||||
patch("agent_loop._tea_get", return_value={"mergeable": True}), \
|
||||
patch("agent_loop.time.sleep"), \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_close.assert_called_once_with(10)
|
||||
|
||||
def test_merge_fails_open_no_conflicts_all_retries_exhausted(self):
|
||||
"""All retries exhausted with PR still open → falls through to State/Question."""
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch",
|
||||
side_effect=[self._open_pr(), self._open_pr(),
|
||||
self._open_pr(), self._open_pr()]), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._merge_pr"), \
|
||||
patch("agent_loop._tea_get", return_value={"mergeable": True}), \
|
||||
patch("agent_loop.time.sleep"), \
|
||||
patch("agent_loop._set_labels") as mock_labels, \
|
||||
patch("agent_loop._comment_issue") as mock_comment, \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_close.assert_not_called()
|
||||
mock_labels.assert_called_once_with(
|
||||
10,
|
||||
add=[agent_loop.LABEL_QUESTION],
|
||||
remove=[agent_loop.LABEL_IN_PROGRESS],
|
||||
)
|
||||
mock_comment.assert_called_once()
|
||||
|
||||
|
||||
class TestHeartbeat(unittest.TestCase):
|
||||
"""Tests for _update_heartbeat() and cmd_monitor()."""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".heartbeat")
|
||||
self._tmp.close()
|
||||
self._orig = agent_loop.HEARTBEAT_FILE
|
||||
agent_loop.HEARTBEAT_FILE = Path(self._tmp.name)
|
||||
Path(self._tmp.name).unlink() # Start with no heartbeat file.
|
||||
|
||||
def tearDown(self):
|
||||
agent_loop.HEARTBEAT_FILE = self._orig
|
||||
Path(self._tmp.name).unlink(missing_ok=True)
|
||||
|
||||
def test_update_heartbeat_writes_timestamp(self):
|
||||
agent_loop._update_heartbeat()
|
||||
content = Path(self._tmp.name).read_text().strip()
|
||||
dt = datetime.fromisoformat(content)
|
||||
age = (datetime.now(timezone.utc) - dt).total_seconds()
|
||||
self.assertLess(age, 5)
|
||||
|
||||
def test_update_heartbeat_creates_file(self):
|
||||
self.assertFalse(Path(self._tmp.name).exists())
|
||||
agent_loop._update_heartbeat()
|
||||
self.assertTrue(Path(self._tmp.name).exists())
|
||||
|
||||
def test_monitor_healthy_when_recent(self):
|
||||
agent_loop._update_heartbeat()
|
||||
result = agent_loop.cmd_monitor()
|
||||
self.assertEqual(result, 0)
|
||||
|
||||
def test_monitor_warns_when_heartbeat_missing(self):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
result = agent_loop.cmd_monitor()
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn("WARNING", buf.getvalue())
|
||||
|
||||
def test_monitor_warns_when_stale(self):
|
||||
stale = (datetime.now(timezone.utc) - timedelta(hours=3)).isoformat()
|
||||
Path(self._tmp.name).write_text(stale)
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
result = agent_loop.cmd_monitor()
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn("WARNING", buf.getvalue())
|
||||
|
||||
def test_monitor_warns_when_corrupted(self):
|
||||
Path(self._tmp.name).write_text("not-a-timestamp")
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
result = agent_loop.cmd_monitor()
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn("WARNING", buf.getvalue())
|
||||
|
||||
def test_run_loop_updates_heartbeat(self):
|
||||
self.assertFalse(Path(self._tmp.name).exists())
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[]):
|
||||
agent_loop._run_loop()
|
||||
self.assertTrue(Path(self._tmp.name).exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -14,7 +14,7 @@ void main() {
|
||||
group('Migration', () {
|
||||
test('schemaVersion matches expected value', () async {
|
||||
final db = AppDatabase(NativeDatabase.memory());
|
||||
expect(db.schemaVersion, 34);
|
||||
expect(db.schemaVersion, 36);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
@@ -202,6 +202,13 @@ void main() {
|
||||
// v34: user_preferences table.
|
||||
await db.customSelect('SELECT count(*) FROM user_preferences').get();
|
||||
|
||||
// v35: mail_view_button_position column on user_preferences.
|
||||
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
|
||||
expect(userPrefsColumns, contains('mail_view_button_position'));
|
||||
|
||||
// v36: after_mail_view_action column on user_preferences.
|
||||
expect(userPrefsColumns, contains('after_mail_view_action'));
|
||||
|
||||
await db.close();
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
});
|
||||
@@ -397,11 +404,18 @@ void main() {
|
||||
// v34: user_preferences table.
|
||||
await db.customSelect('SELECT count(*) FROM user_preferences').get();
|
||||
|
||||
// v35: mail_view_button_position column on user_preferences.
|
||||
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
|
||||
expect(userPrefsColumns, contains('mail_view_button_position'));
|
||||
|
||||
// v36: after_mail_view_action column on user_preferences.
|
||||
expect(userPrefsColumns, contains('after_mail_view_action'));
|
||||
|
||||
await db.close();
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
});
|
||||
|
||||
test('fresh install creates all tables at schemaVersion 34', () async {
|
||||
test('fresh install creates all tables at schemaVersion 36', () async {
|
||||
final db = AppDatabase(NativeDatabase.memory());
|
||||
await db.select(db.accounts).get();
|
||||
|
||||
@@ -448,6 +462,13 @@ void main() {
|
||||
expect(syncLogColumns, contains('error_stack_trace'));
|
||||
expect(syncLogColumns, contains('is_permanent'));
|
||||
|
||||
// v35: mail_view_button_position column on user_preferences.
|
||||
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
|
||||
expect(userPrefsColumns, contains('mail_view_button_position'));
|
||||
|
||||
// v36: after_mail_view_action column on user_preferences.
|
||||
expect(userPrefsColumns, contains('after_mail_view_action'));
|
||||
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -252,5 +252,29 @@ void main() {
|
||||
expect(find.textContaining('flag mismatches: 1'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'sync health row is positioned below the account name row',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts',
|
||||
overrides: baseOverrides(
|
||||
accounts: [kTestAccount],
|
||||
syncHealth: SyncHealthRow(
|
||||
accountId: kTestAccount.id,
|
||||
lastVerifiedAt: DateTime(2024, 6),
|
||||
isHealthy: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final namePos = tester.getTopLeft(find.text('Alice')).dy;
|
||||
final healthPos = tester.getTopLeft(find.textContaining('Healthy')).dy;
|
||||
expect(healthPos, greaterThan(namePos));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -271,7 +271,8 @@ void main() {
|
||||
expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1));
|
||||
});
|
||||
|
||||
testWidgets('Mark as spam button is present in app bar', (tester) async {
|
||||
testWidgets('Mark as spam is in popup menu, not a standalone button',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||
@@ -282,12 +283,19 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// No standalone icon button for mark as spam.
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Tooltip && w.message == 'Mark as spam',
|
||||
),
|
||||
findsOneWidget,
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
// It appears in the popup menu.
|
||||
await tester.tap(find.byType(PopupMenuButton<String>));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Mark as spam'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Mark as spam shows dialog when no junk folder',
|
||||
@@ -304,11 +312,11 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Tooltip && w.message == 'Mark as spam',
|
||||
),
|
||||
);
|
||||
// Open the popup menu first, then tap Mark as spam.
|
||||
await tester.tap(find.byType(PopupMenuButton<String>));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Mark as spam'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('No spam folder found'), findsOneWidget);
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
@@ -414,6 +414,7 @@ class _NoOpManageSieveProbeService implements ManageSieveProbeService {
|
||||
Widget buildApp({
|
||||
required String initialLocation,
|
||||
required List<Override> overrides,
|
||||
UserPreferencesRepository? userPreferences,
|
||||
}) {
|
||||
final testRouter = GoRouter(
|
||||
initialLocation: initialLocation,
|
||||
@@ -523,7 +524,7 @@ Widget buildApp({
|
||||
const NoOpSyncLogRepository(),
|
||||
),
|
||||
userPreferencesRepositoryProvider.overrideWithValue(
|
||||
FakeUserPreferencesRepository(),
|
||||
userPreferences ?? FakeUserPreferencesRepository(),
|
||||
),
|
||||
...overrides,
|
||||
manageSieveProbeServiceProvider.overrideWith(
|
||||
@@ -624,18 +625,37 @@ Email testEmail({
|
||||
class FakeUserPreferencesRepository implements UserPreferencesRepository {
|
||||
FakeUserPreferencesRepository({
|
||||
this.menuPosition = MenuPosition.bottom,
|
||||
this.mailViewButtonPosition = MenuPosition.bottom,
|
||||
this.afterMailViewAction = AfterMailViewAction.nextMessage,
|
||||
});
|
||||
|
||||
MenuPosition menuPosition;
|
||||
MenuPosition mailViewButtonPosition;
|
||||
AfterMailViewAction afterMailViewAction;
|
||||
|
||||
@override
|
||||
Stream<UserPreferences> observePreferences() =>
|
||||
Stream.value(UserPreferences(menuPosition: menuPosition));
|
||||
Stream<UserPreferences> observePreferences() => Stream.value(
|
||||
UserPreferences(
|
||||
menuPosition: menuPosition,
|
||||
mailViewButtonPosition: mailViewButtonPosition,
|
||||
afterMailViewAction: afterMailViewAction,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> updateMenuPosition(MenuPosition position) async {
|
||||
menuPosition = position;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateMailViewButtonPosition(MenuPosition position) async {
|
||||
mailViewButtonPosition = position;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateAfterMailViewAction(AfterMailViewAction action) async {
|
||||
afterMailViewAction = action;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSearchHistoryRepository implements SearchHistoryRepository {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
|
||||
import 'helpers.dart';
|
||||
@@ -142,6 +143,60 @@ void main() {
|
||||
expect(find.byIcon(Icons.expand_more), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows bottom app bar with back button by default', (
|
||||
tester,
|
||||
) async {
|
||||
final email = _threadEmail();
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
|
||||
overrides: [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(emails: [email]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(BottomAppBar), findsOneWidget);
|
||||
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hides bottom app bar when button position is top', (
|
||||
tester,
|
||||
) async {
|
||||
final email = _threadEmail();
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
|
||||
userPreferences: FakeUserPreferencesRepository(
|
||||
mailViewButtonPosition: MenuPosition.top,
|
||||
),
|
||||
overrides: [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(emails: [email]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(BottomAppBar), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('flagged email shows star icon', (tester) async {
|
||||
final email = _threadEmail(isFlagged: true);
|
||||
await tester.pumpWidget(
|
||||
|
||||
@@ -20,11 +20,13 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Menu bar position'), findsOneWidget);
|
||||
expect(find.text('Bottom (default)'), findsOneWidget);
|
||||
expect(find.text('Top'), findsOneWidget);
|
||||
expect(find.text('Bottom (default)'), findsNWidgets(2));
|
||||
expect(find.text('Top'), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('bottom option is selected by default', (tester) async {
|
||||
testWidgets('shows single mail view button position section', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/preferences',
|
||||
@@ -33,12 +35,15 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final radioGroup = find.byType(RadioGroup<MenuPosition>);
|
||||
final widget = tester.widget<RadioGroup<MenuPosition>>(radioGroup);
|
||||
expect(widget.groupValue, MenuPosition.bottom);
|
||||
expect(
|
||||
find.text('Single mail view button position'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('tapping Top option updates the repo', (tester) async {
|
||||
testWidgets('menu position bottom option is selected by default', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/preferences',
|
||||
@@ -47,7 +52,41 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Top'));
|
||||
final radioGroups = find.byType(RadioGroup<MenuPosition>);
|
||||
final menuGroup =
|
||||
tester.widget<RadioGroup<MenuPosition>>(radioGroups.first);
|
||||
expect(menuGroup.groupValue, MenuPosition.bottom);
|
||||
});
|
||||
|
||||
testWidgets('mail view button position bottom is selected by default', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/preferences',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final radioGroups = find.byType(RadioGroup<MenuPosition>);
|
||||
final mailViewGroup =
|
||||
tester.widget<RadioGroup<MenuPosition>>(radioGroups.last);
|
||||
expect(mailViewGroup.groupValue, MenuPosition.bottom);
|
||||
});
|
||||
|
||||
testWidgets('tapping Top in menu position section updates the repo', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/preferences',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Top').first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final repo = ProviderScope.containerOf(
|
||||
@@ -57,5 +96,91 @@ void main() {
|
||||
|
||||
expect(repo.menuPosition, MenuPosition.top);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'tapping Top in mail view button position section updates the repo', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/preferences',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Top').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final repo = ProviderScope.containerOf(
|
||||
tester.element(find.byType(UserPreferencesScreen)),
|
||||
).read(userPreferencesRepositoryProvider)
|
||||
as FakeUserPreferencesRepository;
|
||||
|
||||
expect(repo.mailViewButtonPosition, MenuPosition.top);
|
||||
});
|
||||
|
||||
testWidgets('shows after mail action section', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/preferences',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Scroll down to reveal the new section below the fold.
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -500));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('After mail action'), findsOneWidget);
|
||||
expect(find.text('Next message (default)'), findsOneWidget);
|
||||
expect(find.text('Return to mailbox'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('after mail action next message is selected by default', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/preferences',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -500));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final radioGroups = find.byType(RadioGroup<AfterMailViewAction>);
|
||||
final group =
|
||||
tester.widget<RadioGroup<AfterMailViewAction>>(radioGroups.first);
|
||||
expect(group.groupValue, AfterMailViewAction.nextMessage);
|
||||
});
|
||||
|
||||
testWidgets('tapping Return to mailbox updates the repo', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/preferences',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -500));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Return to mailbox'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final repo = ProviderScope.containerOf(
|
||||
tester.element(find.byType(UserPreferencesScreen)),
|
||||
).read(userPreferencesRepositoryProvider)
|
||||
as FakeUserPreferencesRepository;
|
||||
|
||||
expect(repo.afterMailViewAction, AfterMailViewAction.showMailbox);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user