Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cc44af77a | ||
|
|
91083218d4 | ||
|
|
adc4eb6f6d | ||
|
|
05d00bdf09 | ||
|
|
c45775be92 | ||
|
|
47fc534a8d | ||
|
|
a5928c1aa6 | ||
|
|
7f3cd43d6e |
@@ -17,7 +17,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Detect Android and Linux changes
|
- name: Detect Android and Linux changes
|
||||||
id: diff
|
id: diff
|
||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
data = json.loads(r.read())
|
data = json.loads(r.read())
|
||||||
runs = [
|
runs = [
|
||||||
r for r in data.get("workflow_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 "")
|
print(runs[0].get("commit_sha") or "")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -64,10 +64,17 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Diff the HEAD commit against its parent; fall back to listing HEAD's files
|
# Diff from the last successfully deployed commit to catch all changes since
|
||||||
# when the parent is unavailable (initial commit, shallow clone).
|
# that deploy, not just the most recent commit. Falls back to HEAD~1 when
|
||||||
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|
# LAST_DEPLOYED_SHA is unknown or not in local history.
|
||||||
|| git show --name-only --format= HEAD)
|
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 files:"
|
||||||
echo "$CHANGED"
|
echo "$CHANGED"
|
||||||
@@ -204,48 +211,6 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
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:
|
label-deploy-health:
|
||||||
name: Update Deploy Health Label
|
name: Update Deploy Health Label
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
name: Update Website
|
name: Update Website
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 * * * *' # every hour on the hour
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
|
|||||||
+1
-1
@@ -294,7 +294,7 @@ tasks:
|
|||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
run_dagger "$@" && return 0
|
run_dagger "$@" && return 0
|
||||||
RC=$?
|
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
|
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
|
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
|
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 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)
|
## Tasks (2026-05-26)
|
||||||
|
|
||||||
- **Renovate Bot (Issue #257)**: Renovate Bot runs daily via Forgejo Actions to keep
|
- **Renovate Bot (Issue #257)**: Renovate Bot runs daily via Forgejo Actions to keep
|
||||||
|
|||||||
@@ -120,15 +120,76 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
final health = ref.watch(syncHealthProvider(account.id));
|
final health = ref.watch(syncHealthProvider(account.id));
|
||||||
final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP';
|
final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP';
|
||||||
|
|
||||||
return ListTile(
|
return Column(
|
||||||
leading: const Icon(Icons.account_circle),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
title: Text(account.displayName),
|
children: [
|
||||||
subtitle: Column(
|
ListTile(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
leading: const Icon(Icons.account_circle),
|
||||||
children: [
|
title: Text(account.displayName),
|
||||||
Text('${account.email}\n$typeLabel'),
|
subtitle: Text('${account.email}\n$typeLabel'),
|
||||||
const SizedBox(height: 4),
|
isThreeLine: true,
|
||||||
health.when(
|
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) {
|
data: (h) {
|
||||||
if (h == null) return const Text('Sync health: Not verified yet');
|
if (h == null) return const Text('Sync health: Not verified yet');
|
||||||
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
|
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
|
||||||
@@ -141,7 +202,7 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
color: h.isHealthy ? Colors.green : Colors.orange,
|
color: h.isHealthy ? Colors.green : Colors.orange,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Flexible(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
h.isHealthy
|
h.isHealthy
|
||||||
? 'Healthy'
|
? 'Healthy'
|
||||||
@@ -155,66 +216,8 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
loading: () => const Text('Sync health: checking...'),
|
loading: () => const Text('Sync health: checking...'),
|
||||||
error: (e, _) => Text('Sync health error: $e'),
|
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'),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,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(
|
IconButton(
|
||||||
icon: const Icon(Icons.archive),
|
icon: const Icon(Icons.archive),
|
||||||
tooltip: 'Archive',
|
tooltip: 'Archive',
|
||||||
@@ -121,25 +112,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
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(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_isFlagged ? Icons.star : Icons.star_border,
|
_isFlagged ? Icons.star : Icons.star_border,
|
||||||
@@ -154,10 +126,27 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
),
|
),
|
||||||
PopupMenuButton<String>(
|
PopupMenuButton<String>(
|
||||||
itemBuilder: (ctx) => [
|
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(
|
const PopupMenuItem(
|
||||||
value: 'mark_unread',
|
value: 'mark_unread',
|
||||||
child: Text('Mark as unread'),
|
child: Text('Mark as unread'),
|
||||||
),
|
),
|
||||||
|
const PopupMenuDivider(),
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: 'headers',
|
value: 'headers',
|
||||||
child: Text('Show Mail Headers'),
|
child: Text('Show Mail Headers'),
|
||||||
@@ -172,7 +161,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
onSelected: (value) async {
|
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);
|
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||||
await repo.setFlag(widget.emailId, seen: false);
|
await repo.setFlag(widget.emailId, seen: false);
|
||||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
||||||
|
|||||||
+8
-8
@@ -659,10 +659,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.18.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1088,26 +1088,26 @@ packages:
|
|||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: test
|
name: test
|
||||||
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
|
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.30.0"
|
version: "1.31.0"
|
||||||
test_api:
|
test_api:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.10"
|
version: "0.7.11"
|
||||||
test_core:
|
test_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_core
|
name: test_core
|
||||||
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
|
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.16"
|
version: "0.6.17"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
],
|
],
|
||||||
"labels": ["dependencies"],
|
"labels": ["dependencies"],
|
||||||
"github-actions": {
|
"github-actions": {
|
||||||
"fileMatch": ["^\\.forgejo/workflows/[^/]+\\.ya?ml$"]
|
"enabled": false
|
||||||
},
|
},
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
|
|||||||
+116
-35
@@ -11,15 +11,18 @@ Flow
|
|||||||
a. pending_issue type=="plan" → post resume comment, set State/Planned, exit 0
|
a. pending_issue type=="plan" → post resume comment, set State/Planned, exit 0
|
||||||
b. pending_issue + open PR → check PR branch CI, merge/fix/wait as needed
|
b. pending_issue + open PR → check PR branch CI, merge/fix/wait as needed
|
||||||
c. Catch-up: orphaned issue-N-fix PRs with passing CI → merge them
|
c. Catch-up: orphaned issue-N-fix PRs with passing CI → merge them
|
||||||
d. Main CI running → save pending-ci state, exit 0
|
d. Catch-up: close issues for PRs already merged (e.g., merged manually after
|
||||||
e. Main CI failed → start fix-CI agent (pushes fix to main), exit 0
|
State/Question was set because CI path filter didn't trigger) → exit 0
|
||||||
f. Main CI ok + pending_issue → close the issue, exit 0 (dead code path —
|
e. Catch-up: Renovate PRs with passing CI → merge them
|
||||||
|
f. Main CI running → save pending-ci state, exit 0
|
||||||
|
g. Main CI failed → start fix-CI agent (pushes fix to main), exit 0
|
||||||
|
h. Main CI ok + pending_issue → close the issue, exit 0 (dead code path —
|
||||||
section 2b always returns first)
|
section 2b always returns first)
|
||||||
g. Main CI ok (or no run yet) → find oldest ToPlan issue, start plan agent,
|
i. Main CI ok (or no run yet) → find oldest ToPlan issue, start plan agent,
|
||||||
save state, exit 0
|
save state, exit 0
|
||||||
h. No ToPlan issues → find oldest Ready issue, start issue agent,
|
j. No ToPlan issues → find oldest Ready issue, start issue agent,
|
||||||
save state, exit 0
|
save state, exit 0
|
||||||
i. No Ready issues → print "nothing to do", exit 0
|
k. No Ready issues → print "nothing to do", exit 0
|
||||||
|
|
||||||
Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
|
Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
|
||||||
Plan agents must NOT write any code or create PRs; they only post a plan comment.
|
Plan agents must NOT write any code or create PRs; they only post a plan comment.
|
||||||
@@ -32,7 +35,7 @@ Output is written to ~/.sharedinbox-agent-logs/<session>-<timestamp>.log.
|
|||||||
To resume the Claude conversation, look up the session UUID first:
|
To resume the Claude conversation, look up the session UUID first:
|
||||||
|
|
||||||
scripts/agent_loop.py list # shows NAME and UUID columns
|
scripts/agent_loop.py list # shows NAME and UUID columns
|
||||||
claude --resume <uuid> # use the UUID, NOT the session name
|
claude --resume <uuid> --dangerously-skip-permissions # use the UUID, NOT the session name
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -43,6 +46,8 @@ import shlex
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -120,6 +125,30 @@ def _fgj_run_list(limit: int = 20) -> list[dict]:
|
|||||||
return data if isinstance(data, list) else []
|
return data if isinstance(data, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def _tea_get(path: str) -> dict:
|
||||||
|
"""Make an authenticated GET request to the Codeberg API and return parsed JSON.
|
||||||
|
|
||||||
|
Tries FORGEJO_TOKEN env var first, then ``fgj auth token`` for the token.
|
||||||
|
"""
|
||||||
|
token = os.environ.get("FORGEJO_TOKEN", "")
|
||||||
|
if not token:
|
||||||
|
r = subprocess.run(
|
||||||
|
["fgj", "--hostname", "codeberg.org", "auth", "token"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if r.returncode == 0:
|
||||||
|
token = r.stdout.strip()
|
||||||
|
url = f"https://codeberg.org/api/v1{path}"
|
||||||
|
req = urllib.request.Request(url)
|
||||||
|
if token:
|
||||||
|
req.add_header("Authorization", f"token {token}")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise RuntimeError(f"GET {path}: HTTP {e.code} {e.reason}") from e
|
||||||
|
|
||||||
|
|
||||||
def _set_labels(issue: int, add: list[str], remove: list[str]) -> None:
|
def _set_labels(issue: int, add: list[str], remove: list[str]) -> None:
|
||||||
"""Add/remove labels on an issue via fgj."""
|
"""Add/remove labels on an issue via fgj."""
|
||||||
cmd = ["issue", "edit", str(issue), "--repo", REPO]
|
cmd = ["issue", "edit", str(issue), "--repo", REPO]
|
||||||
@@ -186,7 +215,8 @@ def _latest_main_ci_run() -> dict | None:
|
|||||||
event=push and prettyref=main, so filtering by event alone is not enough.
|
event=push and prettyref=main, so filtering by event alone is not enough.
|
||||||
We also require workflow_id == "ci.yml".
|
We also require workflow_id == "ci.yml".
|
||||||
"""
|
"""
|
||||||
for run in _fgj_run_list(limit=20):
|
data = _tea_get(f"/repos/{REPO}/actions/runs?limit=20")
|
||||||
|
for run in data.get("workflow_runs", []):
|
||||||
if (run.get("event") == "push"
|
if (run.get("event") == "push"
|
||||||
and run.get("prettyref") == "main"
|
and run.get("prettyref") == "main"
|
||||||
and run.get("workflow_id") == "ci.yml"):
|
and run.get("workflow_id") == "ci.yml"):
|
||||||
@@ -197,19 +227,22 @@ def _latest_main_ci_run() -> dict | None:
|
|||||||
def _latest_ci_run_for_branch(branch: str) -> dict | None:
|
def _latest_ci_run_for_branch(branch: str) -> dict | None:
|
||||||
"""Return the latest CI run for a specific branch, or None.
|
"""Return the latest CI run for a specific branch, or None.
|
||||||
|
|
||||||
For push events fgj reports the branch in ``prettyref``; for pull_request
|
For pull_request events the branch is embedded in the JSON ``event_payload``
|
||||||
events ``prettyref`` is ``#N``, so we resolve the PR number first.
|
field; for push events it appears directly in ``prettyref``.
|
||||||
"""
|
"""
|
||||||
runs = _fgj_run_list(limit=20)
|
data = _tea_get(f"/repos/{REPO}/actions/runs?limit=20")
|
||||||
pr_data = _find_pr_for_branch(branch)
|
for run in data.get("workflow_runs", []):
|
||||||
pr_ref = f"#{pr_data['number']}" if pr_data else None
|
|
||||||
for run in runs:
|
|
||||||
if run.get("event") == "pull_request":
|
if run.get("event") == "pull_request":
|
||||||
if pr_ref and run.get("prettyref") == pr_ref:
|
payload_str = run.get("event_payload", "")
|
||||||
return run
|
if payload_str:
|
||||||
elif run.get("event") == "push":
|
try:
|
||||||
if run.get("prettyref") == branch:
|
payload = json.loads(payload_str)
|
||||||
return run
|
if payload.get("pull_request", {}).get("head", {}).get("ref") == branch:
|
||||||
|
return run
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
pass
|
||||||
|
elif run.get("event") == "push" and run.get("prettyref") == branch:
|
||||||
|
return run
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -269,6 +302,35 @@ def _open_renovate_prs() -> list[dict]:
|
|||||||
return renovate_prs
|
return renovate_prs
|
||||||
|
|
||||||
|
|
||||||
|
def _merged_issue_prs() -> list[dict]:
|
||||||
|
"""Return recently merged PRs with issue-{N}-fix branches, oldest-first.
|
||||||
|
|
||||||
|
Used for catch-up: if the loop set State/Question (e.g., no CI run detected)
|
||||||
|
but the PR was later merged manually, we still want to close the issue.
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["fgj", "--hostname", "codeberg.org", "pr", "list",
|
||||||
|
"--repo", REPO, "--state", "closed", "--json"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0 or not result.stdout.strip():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
prs = json.loads(result.stdout)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return []
|
||||||
|
merged = []
|
||||||
|
for pr in prs:
|
||||||
|
if not pr.get("merged"):
|
||||||
|
continue
|
||||||
|
head = pr.get("head", {})
|
||||||
|
ref = head.get("ref") or head.get("label", "").split(":")[-1]
|
||||||
|
if re.match(r"^issue-\d+-fix$", ref or ""):
|
||||||
|
merged.append(pr)
|
||||||
|
merged.sort(key=lambda p: p["number"])
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
def _latest_ci_run_for_pr(pr_number: int) -> dict | None:
|
def _latest_ci_run_for_pr(pr_number: int) -> dict | None:
|
||||||
"""Return the latest CI run triggered by a pull_request event for the given PR number."""
|
"""Return the latest CI run triggered by a pull_request event for the given PR number."""
|
||||||
pr_ref = f"#{pr_number}"
|
pr_ref = f"#{pr_number}"
|
||||||
@@ -307,17 +369,10 @@ def _handle_pr_still_open_after_merge(pr_number: int, branch: str, issue_num: in
|
|||||||
"merged" — PR closed after a retry
|
"merged" — PR closed after a retry
|
||||||
"fallback" — all options exhausted; caller should set State/Question
|
"fallback" — all options exhausted; caller should set State/Question
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
try:
|
||||||
["fgj", "--hostname", "codeberg.org", "pr", "view", str(pr_number),
|
pr_data = _tea_get(f"/repos/{REPO}/pulls/{pr_number}")
|
||||||
"--repo", REPO, "--json"],
|
except RuntimeError:
|
||||||
capture_output=True, text=True,
|
pr_data = {}
|
||||||
)
|
|
||||||
pr_data: dict = {}
|
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
|
||||||
try:
|
|
||||||
pr_data = json.loads(result.stdout)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
mergeable = pr_data.get("mergeable")
|
mergeable = pr_data.get("mergeable")
|
||||||
|
|
||||||
if mergeable is False:
|
if mergeable is False:
|
||||||
@@ -542,7 +597,7 @@ def cmd_list() -> int:
|
|||||||
|
|
||||||
sessions.sort(reverse=True)
|
sessions.sort(reverse=True)
|
||||||
total = len(sessions)
|
total = len(sessions)
|
||||||
print(f" {'DATE':<16} {'NAME':<20} UUID (use with: claude --resume <uuid>)")
|
print(f" {'DATE':<16} {'NAME':<20} UUID (use with: claude --resume <uuid> --dangerously-skip-permissions)")
|
||||||
print(f" {'-'*16} {'-'*20} {'-'*36}")
|
print(f" {'-'*16} {'-'*20} {'-'*36}")
|
||||||
for mtime, name, sid in sessions[:20]:
|
for mtime, name, sid in sessions[:20]:
|
||||||
ts = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")
|
ts = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")
|
||||||
@@ -626,9 +681,9 @@ def _run_loop() -> int:
|
|||||||
session_name = state.get("session_name")
|
session_name = state.get("session_name")
|
||||||
uuid = _find_session_uuid(session_name) if session_name else None
|
uuid = _find_session_uuid(session_name) if session_name else None
|
||||||
if uuid:
|
if uuid:
|
||||||
resume_cmd = f"claude --resume {shlex.quote(uuid)}"
|
resume_cmd = f"claude --resume {shlex.quote(uuid)} --dangerously-skip-permissions"
|
||||||
elif session_name:
|
elif session_name:
|
||||||
resume_cmd = f"claude --resume <uuid> # run: scripts/agent_loop.py list"
|
resume_cmd = f"claude --resume <uuid> --dangerously-skip-permissions # run: scripts/agent_loop.py list"
|
||||||
else:
|
else:
|
||||||
resume_cmd = ""
|
resume_cmd = ""
|
||||||
git_info = _git_summary()
|
git_info = _git_summary()
|
||||||
@@ -657,7 +712,7 @@ def _run_loop() -> int:
|
|||||||
session_name = f"plan-issue-{pending_issue}"
|
session_name = f"plan-issue-{pending_issue}"
|
||||||
uuid = _find_session_uuid(session_name)
|
uuid = _find_session_uuid(session_name)
|
||||||
if uuid:
|
if uuid:
|
||||||
resume_cmd = f"claude --resume {shlex.quote(uuid)}"
|
resume_cmd = f"claude --resume {shlex.quote(uuid)} --dangerously-skip-permissions"
|
||||||
_comment_issue(
|
_comment_issue(
|
||||||
pending_issue,
|
pending_issue,
|
||||||
f"Planning complete. To resume this session:\n\n```\n{resume_cmd}\n```",
|
f"Planning complete. To resume this session:\n\n```\n{resume_cmd}\n```",
|
||||||
@@ -846,7 +901,33 @@ def _run_loop() -> int:
|
|||||||
print(f"Merged PR #{pr_number}.")
|
print(f"Merged PR #{pr_number}.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# ── 2c. Catch-up: merge Renovate PRs with passing CI ─────────────────────
|
# ── 2c. Catch-up: close issues whose PRs were already merged ─────────────
|
||||||
|
# Handles the case where State/Question was set (e.g., no CI run appeared
|
||||||
|
# because the changed paths didn't match ci.yml's path filter) but the PR
|
||||||
|
# was merged manually afterward. The next loop tick closes the issue.
|
||||||
|
for pr in _merged_issue_prs():
|
||||||
|
head = pr.get("head", {})
|
||||||
|
branch = head.get("ref") or head.get("label", "").split(":")[-1]
|
||||||
|
m = re.match(r"^issue-(\d+)-fix$", branch or "")
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
issue_num = int(m.group(1))
|
||||||
|
try:
|
||||||
|
issue_data = _tea_get(f"/repos/{REPO}/issues/{issue_num}")
|
||||||
|
except RuntimeError:
|
||||||
|
continue
|
||||||
|
if issue_data.get("state") != "open":
|
||||||
|
continue
|
||||||
|
pr_number = pr["number"]
|
||||||
|
print(f"Catch-up (merged PR): PR #{pr_number} for issue #{issue_num} was merged — closing.")
|
||||||
|
try:
|
||||||
|
_close_issue(issue_num)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"Catch-up (merged PR): could not close issue #{issue_num}: {e}")
|
||||||
|
continue
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# ── 2d. Catch-up: merge Renovate PRs with passing CI ─────────────────────
|
||||||
# The merge-renovate CI job only fires on pull_request events. If a Renovate
|
# The merge-renovate CI job only fires on pull_request events. If a Renovate
|
||||||
# PR had CI run before that job was added (or the automerge label was absent),
|
# PR had CI run before that job was added (or the automerge label was absent),
|
||||||
# it stays open forever. Detect and merge those here.
|
# it stays open forever. Detect and merge those here.
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ class TestMain(unittest.TestCase):
|
|||||||
|
|
||||||
with patch("agent_loop._read_state", return_value=None), \
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._merged_issue_prs", return_value=[]), \
|
||||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||||
patch("agent_loop._ready_issues", return_value=[self._make_issue(10)]), \
|
patch("agent_loop._ready_issues", return_value=[self._make_issue(10)]), \
|
||||||
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
|
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
|
||||||
@@ -229,6 +230,7 @@ class TestMain(unittest.TestCase):
|
|||||||
|
|
||||||
with patch("agent_loop._read_state", return_value=None), \
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._merged_issue_prs", return_value=[]), \
|
||||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||||
patch("agent_loop._ready_issues", return_value=[self._make_issue(7)]), \
|
patch("agent_loop._ready_issues", return_value=[self._make_issue(7)]), \
|
||||||
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
|
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
|
||||||
@@ -243,6 +245,7 @@ class TestMain(unittest.TestCase):
|
|||||||
"""main() exits cleanly with 0 when there are no ready issues."""
|
"""main() exits cleanly with 0 when there are no ready issues."""
|
||||||
with patch("agent_loop._read_state", return_value=None), \
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._merged_issue_prs", return_value=[]), \
|
||||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||||
patch("agent_loop._ready_issues", return_value=[]), \
|
patch("agent_loop._ready_issues", return_value=[]), \
|
||||||
patch("agent_loop._set_labels") as mock_labels, \
|
patch("agent_loop._set_labels") as mock_labels, \
|
||||||
@@ -263,6 +266,7 @@ class TestMain(unittest.TestCase):
|
|||||||
|
|
||||||
with patch("agent_loop._read_state", return_value=None), \
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._merged_issue_prs", return_value=[]), \
|
||||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||||
patch("agent_loop._ready_issues", return_value=[self._make_issue(42)]), \
|
patch("agent_loop._ready_issues", return_value=[self._make_issue(42)]), \
|
||||||
patch("agent_loop._set_labels"), \
|
patch("agent_loop._set_labels"), \
|
||||||
@@ -442,6 +446,7 @@ class TestPendingCi(unittest.TestCase):
|
|||||||
"type": "ci-fix",
|
"type": "ci-fix",
|
||||||
}), \
|
}), \
|
||||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._merged_issue_prs", return_value=[]), \
|
||||||
patch("agent_loop._latest_main_ci_run", return_value={"id": 1, "status": "success"}), \
|
patch("agent_loop._latest_main_ci_run", return_value={"id": 1, "status": "success"}), \
|
||||||
patch("agent_loop._close_issue") as mock_close, \
|
patch("agent_loop._close_issue") as mock_close, \
|
||||||
patch("agent_loop._ready_issues", return_value=[]), \
|
patch("agent_loop._ready_issues", return_value=[]), \
|
||||||
@@ -459,6 +464,7 @@ class TestOutputFormat(unittest.TestCase):
|
|||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
with patch("agent_loop._read_state", return_value=None), \
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._merged_issue_prs", return_value=[]), \
|
||||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||||
patch("agent_loop._ready_issues", return_value=[]), \
|
patch("agent_loop._ready_issues", return_value=[]), \
|
||||||
contextlib.redirect_stdout(buf):
|
contextlib.redirect_stdout(buf):
|
||||||
@@ -471,6 +477,7 @@ class TestOutputFormat(unittest.TestCase):
|
|||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
with patch("agent_loop._read_state", return_value=None), \
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._merged_issue_prs", return_value=[]), \
|
||||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||||
patch("agent_loop._ready_issues", return_value=[]), \
|
patch("agent_loop._ready_issues", return_value=[]), \
|
||||||
contextlib.redirect_stdout(buf):
|
contextlib.redirect_stdout(buf):
|
||||||
@@ -482,6 +489,7 @@ class TestOutputFormat(unittest.TestCase):
|
|||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
with patch("agent_loop._read_state", return_value=None), \
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._merged_issue_prs", return_value=[]), \
|
||||||
patch("agent_loop._latest_main_ci_run", return_value=run), \
|
patch("agent_loop._latest_main_ci_run", return_value=run), \
|
||||||
contextlib.redirect_stdout(buf):
|
contextlib.redirect_stdout(buf):
|
||||||
agent_loop._run_loop()
|
agent_loop._run_loop()
|
||||||
@@ -493,6 +501,7 @@ class TestOutputFormat(unittest.TestCase):
|
|||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
with patch("agent_loop._read_state", return_value=None), \
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._merged_issue_prs", return_value=[]), \
|
||||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||||
patch("agent_loop._ready_issues", return_value=[issue]), \
|
patch("agent_loop._ready_issues", return_value=[issue]), \
|
||||||
patch("agent_loop._set_labels"), \
|
patch("agent_loop._set_labels"), \
|
||||||
@@ -714,7 +723,7 @@ class TestRunLoopResumeCommand(unittest.TestCase):
|
|||||||
contextlib.redirect_stdout(buf):
|
contextlib.redirect_stdout(buf):
|
||||||
agent_loop._run_loop()
|
agent_loop._run_loop()
|
||||||
output = buf.getvalue()
|
output = buf.getvalue()
|
||||||
self.assertIn(f"claude --resume {fake_uuid}", output)
|
self.assertIn(f"claude --resume {fake_uuid} --dangerously-skip-permissions", output)
|
||||||
|
|
||||||
def test_resume_shows_list_hint_when_uuid_not_found(self):
|
def test_resume_shows_list_hint_when_uuid_not_found(self):
|
||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
@@ -757,6 +766,7 @@ class TestCatchupSkipsQuestionIssues(unittest.TestCase):
|
|||||||
ci_run = {"id": 999, "status": "success"}
|
ci_run = {"id": 999, "status": "success"}
|
||||||
with patch("agent_loop._read_state", return_value=None), \
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
patch("agent_loop._open_issue_prs", return_value=[pr]), \
|
patch("agent_loop._open_issue_prs", return_value=[pr]), \
|
||||||
|
patch("agent_loop._merged_issue_prs", return_value=[]), \
|
||||||
patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \
|
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._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
|
||||||
patch("agent_loop._merge_pr") as mock_merge, \
|
patch("agent_loop._merge_pr") as mock_merge, \
|
||||||
@@ -785,6 +795,71 @@ class TestCatchupSkipsQuestionIssues(unittest.TestCase):
|
|||||||
mock_merge.assert_called_once_with(50)
|
mock_merge.assert_called_once_with(50)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergedPrCatchup(unittest.TestCase):
|
||||||
|
"""Catch-up closes issues whose PRs were already merged outside the normal flow."""
|
||||||
|
|
||||||
|
def _make_merged_pr(self, pr_number=283, branch="issue-282-fix"):
|
||||||
|
return {"number": pr_number, "merged": True, "head": {"ref": branch}}
|
||||||
|
|
||||||
|
def test_closes_issue_when_pr_was_merged(self):
|
||||||
|
"""When a merged issue-N-fix PR exists and the issue still has labels, close it."""
|
||||||
|
pr = self._make_merged_pr()
|
||||||
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._merged_issue_prs", return_value=[pr]), \
|
||||||
|
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
|
||||||
|
patch("agent_loop._close_issue") as mock_close, \
|
||||||
|
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_close.assert_called_once_with(282)
|
||||||
|
|
||||||
|
def test_skips_when_issue_has_no_labels(self):
|
||||||
|
"""When _get_issue_labels returns [] (likely already closed), skip the issue."""
|
||||||
|
pr = self._make_merged_pr()
|
||||||
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._merged_issue_prs", return_value=[pr]), \
|
||||||
|
patch("agent_loop._get_issue_labels", return_value=[]), \
|
||||||
|
patch("agent_loop._close_issue") as mock_close, \
|
||||||
|
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_close.assert_not_called()
|
||||||
|
|
||||||
|
def test_output_mentions_merged_pr_and_issue(self):
|
||||||
|
"""The catch-up log line names the PR number and issue number."""
|
||||||
|
pr = self._make_merged_pr(pr_number=283, branch="issue-282-fix")
|
||||||
|
buf = io.StringIO()
|
||||||
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._merged_issue_prs", return_value=[pr]), \
|
||||||
|
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
|
||||||
|
patch("agent_loop._close_issue"), \
|
||||||
|
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()
|
||||||
|
output = buf.getvalue()
|
||||||
|
self.assertIn("283", output)
|
||||||
|
self.assertIn("282", output)
|
||||||
|
|
||||||
|
def test_continues_on_close_error(self):
|
||||||
|
"""If _close_issue raises, the loop continues instead of crashing."""
|
||||||
|
pr = self._make_merged_pr()
|
||||||
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._merged_issue_prs", return_value=[pr]), \
|
||||||
|
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
|
||||||
|
patch("agent_loop._close_issue", side_effect=RuntimeError("already closed")), \
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
class TestMergeFailsOpen(unittest.TestCase):
|
class TestMergeFailsOpen(unittest.TestCase):
|
||||||
"""Tests for auto-resolution when a PR is still open after the merge command."""
|
"""Tests for auto-resolution when a PR is still open after the merge command."""
|
||||||
|
|
||||||
@@ -928,6 +1003,7 @@ class TestHeartbeat(unittest.TestCase):
|
|||||||
self.assertFalse(Path(self._tmp.name).exists())
|
self.assertFalse(Path(self._tmp.name).exists())
|
||||||
with patch("agent_loop._read_state", return_value=None), \
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._merged_issue_prs", return_value=[]), \
|
||||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||||
patch("agent_loop._ready_issues", return_value=[]):
|
patch("agent_loop._ready_issues", return_value=[]):
|
||||||
agent_loop._run_loop()
|
agent_loop._run_loop()
|
||||||
|
|||||||
@@ -252,5 +252,29 @@ void main() {
|
|||||||
expect(find.textContaining('flag mismatches: 1'), findsOneWidget);
|
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));
|
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(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||||
@@ -282,12 +283,19 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// No standalone icon button for mark as spam.
|
||||||
expect(
|
expect(
|
||||||
find.byWidgetPredicate(
|
find.byWidgetPredicate(
|
||||||
(w) => w is Tooltip && w.message == 'Mark as spam',
|
(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',
|
testWidgets('Mark as spam shows dialog when no junk folder',
|
||||||
@@ -304,11 +312,11 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(
|
// Open the popup menu first, then tap Mark as spam.
|
||||||
find.byWidgetPredicate(
|
await tester.tap(find.byType(PopupMenuButton<String>));
|
||||||
(w) => w is Tooltip && w.message == 'Mark as spam',
|
await tester.pumpAndSettle();
|
||||||
),
|
|
||||||
);
|
await tester.tap(find.text('Mark as spam'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('No spam folder found'), findsOneWidget);
|
expect(find.text('No spam folder found'), findsOneWidget);
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
Reference in New Issue
Block a user