Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fc26057d7 | ||
|
|
3019fdf145 | ||
|
|
14342f6472 | ||
|
|
b86c1a5c69 | ||
|
|
6e22683f5b | ||
|
|
dc181d0d85 | ||
|
|
e37d8066cb | ||
|
|
1b1f9788fd | ||
|
|
19d8d282ba | ||
|
|
aa59dbb852 | ||
|
|
826488192d | ||
|
|
d683da9c59 | ||
|
|
77fc6964f6 | ||
|
|
47824c5711 | ||
|
|
a6c231f293 |
@@ -74,6 +74,10 @@ jobs:
|
||||
run: task publish-android
|
||||
|
||||
- name: Build & Deploy APK to server
|
||||
# continue-on-error: step requires SSH_PRIVATE_KEY secret; if unset the task
|
||||
# precondition fails, but we don't want that to fail the whole job — the Play
|
||||
# Store publish above already succeeded. The overall job stays green even
|
||||
# though this step shows as failed/orange in the UI.
|
||||
continue-on-error: true
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
@@ -113,6 +117,11 @@ jobs:
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Build & Deploy Linux to server
|
||||
# continue-on-error: step requires SSH_PRIVATE_KEY secret; if unset the task
|
||||
# precondition fails, but the build step that precedes this (done via Dagger)
|
||||
# already succeeded. Deployment is best-effort; a missing secret should not
|
||||
# turn the job red. The step will show as failed/orange in the UI even though
|
||||
# the overall job is green — this is intentional.
|
||||
continue-on-error: true
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
@@ -154,6 +163,8 @@ jobs:
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Generate build history and deploy website
|
||||
# continue-on-error: website publish is best-effort; a missing SSH_PRIVATE_KEY
|
||||
# should not block the overall workflow status.
|
||||
continue-on-error: true
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
|
||||
@@ -91,3 +91,93 @@ The CI workflow in `.forgejo/workflows/ci.yml` is configured to use the Dagger m
|
||||
- **Check Suite:** Runs analysis and tests in parallel.
|
||||
- **Builds:** Produces Linux and Android artifacts.
|
||||
- **Caching:** When using the shared engine, CI runners benefit from the persistent cache on the host.
|
||||
|
||||
## Credential Security — Keeping Production Secrets Off Codeberg
|
||||
|
||||
### Problem
|
||||
|
||||
The current setup stores two categories of secrets in Codeberg repository secrets:
|
||||
|
||||
1. **Dagger access credentials** — TLS certificates used to connect to the remote Dagger engine via stunnel (`DAGGER_CA_CERT`, `DAGGER_CLIENT_CERT`, `DAGGER_CLIENT_KEY`, `DAGGER_STUNNEL_URL`).
|
||||
2. **Production secrets** — actual credentials for external services: `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `PLAY_STORE_CONFIG_JSON`, `SSH_PRIVATE_KEY`, `FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY`.
|
||||
|
||||
If Codeberg is compromised, both categories are leaked. The Dagger TLS certificates enable access only to the Dagger engine and have limited blast radius. But the production secrets give direct access to the Play Store, the Android signing key, the deployment server, and Firebase — a much larger blast radius.
|
||||
|
||||
**Goal:** Keep only Dagger access credentials in Codeberg. Store all production secrets on the Dagger host machine so they never touch Codeberg.
|
||||
|
||||
### Option 1: Runner-level environment variables
|
||||
|
||||
Store production secrets as environment variables in the Forgejo runner's systemd service (e.g., via a `EnvironmentFile=` in the service override). The runner injects host env vars into job processes automatically. CI workflows drop the `${{ secrets.XYZ }}` references for production secrets entirely — the variables are already present in the job environment.
|
||||
|
||||
**Pro:**
|
||||
- No new infrastructure required.
|
||||
- Works with the existing `dagger call --progress=plain --secret env:VAR_NAME` argument style.
|
||||
- Secrets never enter Codeberg.
|
||||
- Straightforward to set up on a single self-hosted runner.
|
||||
|
||||
**Con:**
|
||||
- Env vars are visible to every process on the runner host (e.g., via `/proc/<pid>/environ`).
|
||||
- Rotating a secret requires host access (no API).
|
||||
- Does not scale cleanly to multiple runners without a shared secrets mechanism.
|
||||
|
||||
### Option 2: Secret files on the CI host with restricted permissions
|
||||
|
||||
Store production secrets as files owned by the runner user with mode `600` (e.g., `/home/forgejo-runner/secrets/play_store.json`). A small setup script reads the files and either exports them as env vars or passes them directly as file-type arguments to `dagger call --progress=plain`. CI workflows contain no secret references at all.
|
||||
|
||||
**Pro:**
|
||||
- OS-level file permissions limit access to the runner user.
|
||||
- Natural format for JSON payloads and key files.
|
||||
- Easy to audit (list files, check mtime).
|
||||
- No new infrastructure.
|
||||
|
||||
**Con:**
|
||||
- Plaintext files on disk; root or backup access exposes them.
|
||||
- Workflow must know file paths (either hardcoded or by convention).
|
||||
- Rotation still requires host filesystem access.
|
||||
|
||||
### Option 3: Dagger host as pipeline orchestrator
|
||||
|
||||
Instead of the CI runner invoking the Dagger CLI directly, the CI job sends a trigger to the Dagger host over SSH. The Dagger host runs the pipeline locally against its own environment, where secrets live as env vars or files. Codeberg only stores the SSH key to reach the Dagger host — not the production secrets.
|
||||
|
||||
```yaml
|
||||
# CI job only does this:
|
||||
- name: Trigger pipeline on Dagger host
|
||||
run: ssh dagger-host "cd sharedinbox && task publish-android"
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.DAGGER_TRIGGER_SSH_KEY }}
|
||||
```
|
||||
|
||||
**Pro:**
|
||||
- Production secrets never leave the Dagger host.
|
||||
- Codeberg stores exactly one secret: the trigger SSH key.
|
||||
- All deployment logic and secrets are fully contained on the host.
|
||||
|
||||
**Con:**
|
||||
- Harder to stream structured CI logs back to Codeberg Actions.
|
||||
- Dynamic context (commit SHA, PR branch) must be passed explicitly over SSH.
|
||||
- The trigger SSH key still grants shell access to the host, so its compromise has its own blast radius.
|
||||
- CI becomes a "fire-and-forget" call, making failure attribution harder.
|
||||
|
||||
### Option 4: External secret manager (e.g., HashiCorp Vault)
|
||||
|
||||
Run a secret manager co-located with the Dagger host. The CI job authenticates with a short-lived AppRole credential (stored in Codeberg) and retrieves secrets at runtime. Vault can also be configured with IP-allow-lists to further restrict who can authenticate.
|
||||
|
||||
**Pro:**
|
||||
- Full audit trail: every secret read is logged with a timestamp and caller identity.
|
||||
- Fine-grained access control per secret.
|
||||
- Built-in versioning and rotation support.
|
||||
- Industry-standard approach; scales to team or multi-runner setups.
|
||||
|
||||
**Con:**
|
||||
- Significant additional infrastructure to install, configure, and maintain.
|
||||
- Vault credentials (RoleID + SecretID) still need to be in Codeberg, though with a smaller blast radius than raw secrets.
|
||||
- Vault itself becomes a security-critical single point of failure.
|
||||
- Operational overhead likely disproportionate for a small single-developer project.
|
||||
|
||||
### Recommendation
|
||||
|
||||
**Option 1** (runner-level env vars) or **Option 2** (secret files) are the pragmatic starting point for a single self-hosted runner. They require no new infrastructure and move all production secrets off Codeberg immediately.
|
||||
|
||||
**Option 3** (Dagger host as orchestrator) is worth considering once the trigger SSH key replaces all other secrets in Codeberg — it offers the cleanest security boundary at the cost of reduced CI observability.
|
||||
|
||||
**Option 4** (Vault) becomes worthwhile if the project grows to multiple runners or team members who each need audited access to deploy credentials.
|
||||
|
||||
+8
-4
@@ -221,7 +221,7 @@ func (m *Ci) pubGetLayer() *dagger.Container {
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter pub get >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -vE '^[+~><] ' "$tmp" || true`}).
|
||||
`grep -vE '^(\+|Downloading packages)' "$tmp" || true`}).
|
||||
WithExec([]string{"python3", "-c",
|
||||
"import json, os\n" +
|
||||
"f='.dart_tool/package_config.json'; d=json.load(open(f)); [d.pop(k,None) for k in ('generated','generatorVersion')]; json.dump(d,open(f,'w'))\n" +
|
||||
@@ -245,7 +245,7 @@ func (m *Ci) codegenBase() *dagger.Container {
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -vE '^\[' "$tmp" || true`})
|
||||
`grep -vE '^\[.*s\] \|' "$tmp" || true`})
|
||||
}
|
||||
|
||||
// setup overlays platform-specific source files onto the shared codegen base.
|
||||
@@ -312,6 +312,7 @@ func (m *Ci) Hugo() *dagger.Container {
|
||||
From("alpine:3.21").
|
||||
WithExec([]string{"apk", "--no-cache", "add", "curl", "tar", "libc6-compat", "libstdc++", "gcompat"}).
|
||||
WithExec([]string{"curl", "-sL", "https://github.com/gohugoio/hugo/releases/download/v0.152.2/hugo_extended_0.152.2_linux-amd64.tar.gz", "-o", "/tmp/hugo.tar.gz"}).
|
||||
WithExec([]string{"sh", "-c", "echo '416bcfbdf5f68469ec9644dbe507da50fc21b94b69a125b059d64ed2cb4d8c27 /tmp/hugo.tar.gz' | sha256sum -c -"}).
|
||||
WithExec([]string{"tar", "-xzf", "/tmp/hugo.tar.gz", "-C", "/usr/local/bin", "hugo"}).
|
||||
WithExec([]string{"rm", "/tmp/hugo.tar.gz"})
|
||||
}
|
||||
@@ -410,7 +411,7 @@ func (m *Ci) CheckMocks(ctx context.Context) (string, error) {
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -vE '^\[' "$tmp" || true`}).
|
||||
`grep -vE '^\[.*s\] \|' "$tmp" || true`}).
|
||||
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . -name '*.mocks.dart' | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Mocks are out of date\"; exit 1; fi; echo \"Mocks are up to date.\""}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
@@ -649,9 +650,12 @@ func (m *Ci) DeployApk(
|
||||
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
|
||||
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
|
||||
built := m.setup(m.firebaseSrc()).
|
||||
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"), dagger.ContainerWithMountedCacheOpts{Owner: "ci"}).
|
||||
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
|
||||
WithWorkdir("/src/android").
|
||||
WithExec([]string{"./gradlew", "app:assembleAndroidTest"}).
|
||||
// --no-daemon avoids connecting to a stale daemon whose registry file was
|
||||
// preserved in the Dagger layer snapshot but whose process no longer exists.
|
||||
WithExec([]string{"./gradlew", "--no-daemon", "app:assembleAndroidTest"}).
|
||||
WithWorkdir("/src").
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`apk=$(find /src -path "*androidTest*" -name "*.apk" -type f 2>/dev/null | head -1) && \
|
||||
|
||||
+14
-106
@@ -1,24 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Cron deploy script for sharedinbox website.
|
||||
Runs every 5 minutes; skips if origin/main has not changed since last successful deploy.
|
||||
Gives up and creates a Codeberg issue after 5 consecutive failures on the same commit.
|
||||
Runs every 5 minutes; skips if origin/main has not changed since last trigger.
|
||||
Triggers the 'Deploy Website' Forgejo Actions workflow via fgj on each new commit.
|
||||
Forgejo Actions handles failure reporting.
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
REPO_DIR = Path(__file__).parent.resolve()
|
||||
SHA_FILE = REPO_DIR / '.last_deployed_sha'
|
||||
FAILED_SHA_FILE = REPO_DIR / '.last_failed_sha'
|
||||
FAIL_COUNT_FILE = REPO_DIR / '.fail_count'
|
||||
ERROR_FILE = REPO_DIR / '.last_deploy_error'
|
||||
ISSUE_SHA_FILE = REPO_DIR / '.last_issue_sha'
|
||||
|
||||
MAX_FAILURES = 5
|
||||
SHA_FILE = REPO_DIR / '.last_deployed_sha'
|
||||
REPO = 'guettli/sharedinbox'
|
||||
CODEBERG = 'https://codeberg.org'
|
||||
|
||||
|
||||
def git(*args):
|
||||
@@ -32,115 +25,30 @@ def read(path: Path) -> str:
|
||||
return path.read_text().strip() if path.exists() else ''
|
||||
|
||||
|
||||
def read_int(path: Path) -> int:
|
||||
try:
|
||||
return int(read(path))
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def issue_exists_for(sha: str) -> bool:
|
||||
"""Check Codeberg for an open issue referencing this commit SHA."""
|
||||
result = subprocess.run(
|
||||
['tea', 'issue', 'list', '--repo', REPO, '--state', 'open',
|
||||
'--limit', '50', '--output', 'simple'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
return sha[:8] in result.stdout
|
||||
|
||||
|
||||
def create_issue(failed_sha: str, fail_count: int) -> None:
|
||||
error_output = read(ERROR_FILE)
|
||||
tail = '\n'.join(error_output.splitlines()[-40:]) if error_output else '(no output captured)'
|
||||
commit_url = f'{CODEBERG}/{REPO}/commit/{failed_sha}'
|
||||
script_url = f'{CODEBERG}/{REPO}/src/branch/main/deploy_cron.py'
|
||||
timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
|
||||
|
||||
title = f'Deploy failed {fail_count}x on {failed_sha[:8]} — needs fix'
|
||||
body = f"""\
|
||||
## Deploy failure — action needed
|
||||
|
||||
The automated deploy cron failed **{fail_count} times** on commit \
|
||||
[{failed_sha[:8]}]({commit_url}) and has stopped retrying.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Detected** | {timestamp} |
|
||||
| **Failing commit** | [{failed_sha}]({commit_url}) |
|
||||
| **Failures** | {fail_count} / {MAX_FAILURES} |
|
||||
| **Deploy script** | [deploy_cron.py]({script_url}) |
|
||||
| **Log file** | `~/si-deploy-cron/deploy.log` |
|
||||
|
||||
### Last deploy output
|
||||
|
||||
```
|
||||
{tail}
|
||||
```
|
||||
|
||||
### Next steps
|
||||
|
||||
Push a fix to `main` — the cron (every 5 min) will retry automatically on the next commit.
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
['tea', 'issue', 'create',
|
||||
'--repo', REPO,
|
||||
'--title', title,
|
||||
'--description', body,
|
||||
'--labels', 'State/Ready,Prio/High'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f'Failed to create issue: {result.stderr}', file=sys.stderr)
|
||||
else:
|
||||
print(f'Issue created: {result.stdout.strip()}')
|
||||
|
||||
|
||||
def main():
|
||||
git('fetch', 'origin', 'main')
|
||||
try:
|
||||
git('fetch', 'origin', 'main')
|
||||
except subprocess.CalledProcessError as exc:
|
||||
print(f'git fetch failed (transient?): {exc} — skipping this run.', file=sys.stderr)
|
||||
return
|
||||
remote_sha = git('rev-parse', 'origin/main')
|
||||
|
||||
last_sha = read(SHA_FILE)
|
||||
last_failed = read(FAILED_SHA_FILE)
|
||||
fail_count = read_int(FAIL_COUNT_FILE) if remote_sha == last_failed else 0
|
||||
last_issue = read(ISSUE_SHA_FILE)
|
||||
last_sha = read(SHA_FILE)
|
||||
|
||||
if remote_sha == last_sha:
|
||||
print(f'No changes since {remote_sha[:8]}, skipping.')
|
||||
return
|
||||
|
||||
if fail_count >= MAX_FAILURES:
|
||||
if remote_sha != last_issue and not issue_exists_for(remote_sha):
|
||||
print(f'{remote_sha[:8]} failed {fail_count}x — creating issue.')
|
||||
create_issue(remote_sha, fail_count)
|
||||
ISSUE_SHA_FILE.write_text(remote_sha + '\n')
|
||||
else:
|
||||
print(f'{remote_sha[:8]} failed {fail_count}x, issue already exists, skipping.')
|
||||
return
|
||||
|
||||
attempt = fail_count + 1
|
||||
print(f'Deploying {remote_sha[:8]} (attempt {attempt}/{MAX_FAILURES}, was {last_sha[:8] or "none"})...')
|
||||
git('pull', '--ff-only', 'origin', 'main')
|
||||
|
||||
print(f'New commit {remote_sha[:8]} (was {last_sha[:8] or "none"}) — triggering workflow...')
|
||||
result = subprocess.run(
|
||||
['task', 'publish-website'],
|
||||
cwd=REPO_DIR,
|
||||
['fgj', 'actions', 'workflow', 'run', 'website.yml', '-R', REPO],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
combined = result.stdout + result.stderr
|
||||
print(combined, end='')
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f'Deploy failed (exit {result.returncode}), attempt {attempt}/{MAX_FAILURES}', file=sys.stderr)
|
||||
FAILED_SHA_FILE.write_text(remote_sha + '\n')
|
||||
FAIL_COUNT_FILE.write_text(str(attempt) + '\n')
|
||||
ERROR_FILE.write_text(combined)
|
||||
print(f'fgj workflow run failed: {result.stderr}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
SHA_FILE.write_text(remote_sha + '\n')
|
||||
for f in (FAILED_SHA_FILE, FAIL_COUNT_FILE, ERROR_FILE, ISSUE_SHA_FILE):
|
||||
f.unlink(missing_ok=True)
|
||||
print('Deploy complete.')
|
||||
print('Workflow triggered.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -13,7 +13,7 @@ Future<void> initNotifications() async {
|
||||
try {
|
||||
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
await _plugin.initialize(
|
||||
const InitializationSettings(android: android),
|
||||
settings: const InitializationSettings(android: android),
|
||||
onDidReceiveNotificationResponse: (_) {},
|
||||
);
|
||||
await _plugin
|
||||
@@ -31,10 +31,10 @@ Future<void> initNotifications() async {
|
||||
Future<void> showNewMailNotification(String accountEmail) async {
|
||||
if (!Platform.isAndroid || !_initialized) return;
|
||||
await _plugin.show(
|
||||
accountEmail.hashCode & 0x7FFFFFFF,
|
||||
'New mail',
|
||||
accountEmail,
|
||||
const NotificationDetails(
|
||||
id: accountEmail.hashCode & 0x7FFFFFFF,
|
||||
title: 'New mail',
|
||||
body: accountEmail,
|
||||
notificationDetails: const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
_kChannelId,
|
||||
_kChannelName,
|
||||
|
||||
@@ -596,8 +596,10 @@ Future<void> initDatabasePath() async {
|
||||
Future<String> _resolveDatabasePath() async {
|
||||
if (_dbPath != null) return _dbPath!;
|
||||
// initDatabasePath() failed (channel not ready before runApp). Retry now
|
||||
// that the engine is fully initialised, with brief back-off.
|
||||
const delays = [100, 300, 600];
|
||||
// that the engine is fully initialised, with back-off. Some slow Android
|
||||
// devices need several seconds for the Pigeon channel to become ready
|
||||
// (issue #166), so use a longer schedule than the initial attempt.
|
||||
const delays = [200, 500, 1000, 2000, 4000];
|
||||
for (final ms in delays) {
|
||||
try {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
@@ -614,6 +616,11 @@ Future<String> _resolveDatabasePath() async {
|
||||
);
|
||||
}
|
||||
|
||||
// These two functions are only called from unit tests (database_path_test.dart).
|
||||
// They expose internals that cannot be reached via the public API.
|
||||
Future<String> resolveDatabasePathForTesting() => _resolveDatabasePath();
|
||||
void resetDatabasePathForTesting() => _dbPath = null;
|
||||
|
||||
LazyDatabase _openConnection() {
|
||||
return LazyDatabase(() async {
|
||||
final file = File(await _resolveDatabasePath());
|
||||
|
||||
@@ -162,7 +162,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
}
|
||||
|
||||
Future<void> _pickAttachments() async {
|
||||
final result = await FilePicker.platform.pickFiles(allowMultiple: true);
|
||||
final result = await FilePicker.pickFiles();
|
||||
if (result == null) return;
|
||||
final files = result.files.where((f) => f.path != null).toList();
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -15,6 +15,8 @@ class CrashScreen extends StatelessWidget {
|
||||
final Object exception;
|
||||
final StackTrace? stackTrace;
|
||||
|
||||
static const _gitHash = String.fromEnvironment('GIT_HASH');
|
||||
|
||||
Future<String> _buildReport() async {
|
||||
String version = 'unknown';
|
||||
try {
|
||||
@@ -23,7 +25,11 @@ class CrashScreen extends StatelessWidget {
|
||||
} catch (_) {}
|
||||
final platform =
|
||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
||||
final gitLine = _gitHash.isNotEmpty
|
||||
? 'Git Commit: [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)\n'
|
||||
: '';
|
||||
return 'App Version: $version\n'
|
||||
'$gitLine'
|
||||
'Platform: $platform\n\n'
|
||||
'Error:\n```\n$exception\n```\n\n'
|
||||
'Stack Trace:\n```\n$stackTrace\n```';
|
||||
@@ -50,6 +56,14 @@ class CrashScreen extends StatelessWidget {
|
||||
style: Theme.of(ctx).textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (_gitHash.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Git Commit: $_gitHash',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Error Details:',
|
||||
@@ -92,6 +106,32 @@ class CrashScreen extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_gitHash.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Git Commit:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
final url = Uri.parse(
|
||||
'https://codeberg.org/guettli/sharedinbox/commit/$_gitHash',
|
||||
);
|
||||
await launchUrl(
|
||||
url,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
child: const Text(
|
||||
_gitHash,
|
||||
style: TextStyle(
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: () async {
|
||||
|
||||
+56
-40
@@ -313,6 +313,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
ffi_leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi_leak_tracker
|
||||
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.2"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -325,10 +333,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
|
||||
sha256: "0204695694b687b167fd497da5252e9f4aaa162e8d274d6fa1e757380f2a5f46"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.7"
|
||||
version: "12.0.0-beta.4"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -351,34 +359,42 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c"
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
version: "6.0.0"
|
||||
flutter_local_notifications:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610
|
||||
sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "18.0.1"
|
||||
version: "21.0.0"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01"
|
||||
sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
version: "8.0.0"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52"
|
||||
sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.0"
|
||||
version: "11.0.0"
|
||||
flutter_local_notifications_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_windows
|
||||
sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
flutter_markdown_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -407,26 +423,26 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "6848263f9744072d0977347c383fb8b57d9780319a6bf5238b5a2866a029de62"
|
||||
sha256: d2a6ac2df7353f5ca47eb159a5407c1dba7ec48ca0e02dc38c9ff4d29447b261
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.2.0"
|
||||
version: "10.3.0"
|
||||
flutter_secure_storage_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_darwin
|
||||
sha256: "67cd1ff671add31dc13e45194398187a04bb63804b37fa47866afae296d73fcb"
|
||||
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
version: "0.3.2"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
|
||||
sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
version: "3.0.1"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -447,10 +463,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
|
||||
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
version: "4.2.2"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -486,10 +502,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: go_router
|
||||
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
|
||||
sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.8.1"
|
||||
version: "17.2.3"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -587,10 +603,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
version: "6.1.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -643,10 +659,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: mobile_scanner
|
||||
sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760
|
||||
sha256: c92c26bf2231695b6d3477c8dcf435f51e28f87b1745966b1fe4c47a286171ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.2.3"
|
||||
version: "7.2.0"
|
||||
mockito:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -699,18 +715,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
||||
sha256: "4bf625947f6c7713ee242296a682e23e44823c09cf9d79e4f1238923c92db852"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.1"
|
||||
version: "10.1.0"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_platform_interface
|
||||
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
||||
sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
version: "4.1.0"
|
||||
path:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -883,18 +899,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: share_plus
|
||||
sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa"
|
||||
sha256: a857d8b1479250aff6b57a51b2c02d31ca05848d441817c43f1640c885c286c0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.0.2"
|
||||
version: "13.1.0"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_platform_interface
|
||||
sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a"
|
||||
sha256: "7f7ae28cf400d13f811e297ff37742dba83b79e0a6f5dce14eec0248274e6ce9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
version: "7.1.0"
|
||||
shelf:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -976,10 +992,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqlite3_flutter_libs
|
||||
sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad
|
||||
sha256: "3ed7553eee7bb368f8950f58ba29f634e06e813c029aff6a0d60862b96de8454"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.42"
|
||||
version: "0.6.0+eol"
|
||||
sqlparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1080,10 +1096,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timezone
|
||||
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
||||
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.1"
|
||||
version: "0.11.0"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1104,10 +1120,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
|
||||
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.29"
|
||||
version: "6.3.30"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1264,10 +1280,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
version: "6.3.0"
|
||||
workmanager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
+8
-8
@@ -19,7 +19,7 @@ dependencies:
|
||||
|
||||
# Local persistence (offline-first)
|
||||
drift: ^2.20.3
|
||||
sqlite3_flutter_libs: ^0.5.28
|
||||
sqlite3_flutter_libs: ^0.6.0+eol
|
||||
path_provider: ^2.1.5
|
||||
path: ^1.9.1
|
||||
|
||||
@@ -27,7 +27,7 @@ dependencies:
|
||||
flutter_riverpod: ^2.6.1
|
||||
|
||||
# Navigation
|
||||
go_router: ^14.8.1
|
||||
go_router: ^17.2.3
|
||||
|
||||
# Secure credential storage (passwords)
|
||||
flutter_secure_storage: ^10.0.0
|
||||
@@ -36,7 +36,7 @@ dependencies:
|
||||
intl: any
|
||||
|
||||
# File picking (compose attachments) and opening downloaded attachments
|
||||
file_picker: ^8.0.0
|
||||
file_picker: ^12.0.0-beta.4
|
||||
open_filex: ^4.6.0
|
||||
mime: ^2.0.0
|
||||
|
||||
@@ -47,7 +47,7 @@ dependencies:
|
||||
cryptography: ^2.7.0
|
||||
|
||||
# QR code scanning (camera) for secure account import
|
||||
mobile_scanner: ^5.0.0
|
||||
mobile_scanner: ^7.2.0
|
||||
|
||||
# HTML rendering for email bodies
|
||||
webview_flutter: ^4.0.0
|
||||
@@ -55,19 +55,19 @@ dependencies:
|
||||
flutter_markdown_plus: ^1.0.7
|
||||
|
||||
# Background sync and local notifications
|
||||
flutter_local_notifications: ^18.0.1
|
||||
flutter_local_notifications: ^21.0.0
|
||||
workmanager: ^0.9.0
|
||||
|
||||
# App version metadata for crash reports
|
||||
package_info_plus: ^8.0.0
|
||||
share_plus: ^12.0.2
|
||||
package_info_plus: ^10.1.0
|
||||
share_plus: ^13.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^4.0.0
|
||||
flutter_lints: ^6.0.0
|
||||
drift_dev: ^2.20.3
|
||||
build_runner: ^2.4.13
|
||||
test: ^1.25.0
|
||||
|
||||
+62
-9
@@ -22,9 +22,10 @@ State file: ~/.sharedinbox-agent-state.json
|
||||
"started_at": "2026-05-15T12:00:00+00:00", "type": "issue" }
|
||||
|
||||
Output is written to ~/.sharedinbox-agent-logs/<session>-<timestamp>.log.
|
||||
Resume the Claude conversation afterward with:
|
||||
To resume the Claude conversation, look up the session UUID first:
|
||||
|
||||
claude --resume issue-91
|
||||
scripts/agent_loop.py list # shows NAME and UUID columns
|
||||
claude --resume <uuid> # use the UUID, NOT the session name
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -169,11 +170,11 @@ def _latest_ci_run_for_branch(branch: str) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def _find_pr_for_branch(branch: str) -> dict | None:
|
||||
"""Return the first open PR whose head branch matches, or None."""
|
||||
def _find_pr_for_branch(branch: str, state: str = "open") -> dict | None:
|
||||
"""Return the first PR in the given state whose head branch matches, or None."""
|
||||
result = subprocess.run(
|
||||
["fgj", "--hostname", "codeberg.org", "pr", "list",
|
||||
"--repo", REPO, "--state", "open", "--json"],
|
||||
"--repo", REPO, "--state", state, "--json"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
@@ -225,6 +226,30 @@ def _clear_state() -> None:
|
||||
STATE_FILE.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _find_session_uuid(session_name: str) -> str | None:
|
||||
"""Return the Claude session UUID for *session_name*, or None if not found.
|
||||
|
||||
Claude stores session metadata in JSONL files; the first entry with
|
||||
type=="agent-name" contains both the human-readable name and the UUID
|
||||
needed for ``claude --resume <uuid>``.
|
||||
"""
|
||||
if not CLAUDE_PROJECTS_DIR.exists():
|
||||
return None
|
||||
for jsonl in CLAUDE_PROJECTS_DIR.glob("*.jsonl"):
|
||||
try:
|
||||
with jsonl.open() as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
d = json.loads(line)
|
||||
if d.get("type") == "agent-name" and d.get("agentName") == session_name:
|
||||
return d.get("sessionId")
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
# ── agent launcher ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -255,7 +280,7 @@ def _start_agent(prompt: str, session_name: str) -> int:
|
||||
proc.stdin.close()
|
||||
|
||||
print(f"Started agent pid={proc.pid}, log={log_file}")
|
||||
print(f" Resume: claude --resume {shlex.quote(session_name)}")
|
||||
print(f" Resume: run 'scripts/agent_loop.py list' to get the UUID-based resume command")
|
||||
return proc.pid
|
||||
|
||||
|
||||
@@ -399,7 +424,13 @@ def _run_loop() -> int:
|
||||
return 1
|
||||
|
||||
session_name = state.get("session_name")
|
||||
resume_cmd = f"claude --resume {shlex.quote(session_name)}" if session_name else ""
|
||||
uuid = _find_session_uuid(session_name) if session_name else None
|
||||
if uuid:
|
||||
resume_cmd = f"claude --resume {shlex.quote(uuid)}"
|
||||
elif session_name:
|
||||
resume_cmd = f"claude --resume <uuid> # run: scripts/agent_loop.py list"
|
||||
else:
|
||||
resume_cmd = ""
|
||||
git_info = _git_summary()
|
||||
parts = [
|
||||
f"Agent pid={pid!r} ({kind}, {issue_ref}) still running ({age/60:.0f} min). Waiting.",
|
||||
@@ -480,12 +511,33 @@ def _run_loop() -> int:
|
||||
return 0
|
||||
|
||||
# CI passed on the PR branch — squash-merge and close.
|
||||
print(f"CI passed on branch {branch!r} — merging PR #{pr_number}.")
|
||||
print(f"CI passed {_ci_run_url(pr_run['id'])} on branch {branch!r} — merging PR #{pr_number}.")
|
||||
_merge_pr(pr_number)
|
||||
_close_issue(pending_issue)
|
||||
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
|
||||
return 0
|
||||
|
||||
# No open PR — check if it was already merged.
|
||||
merged_pr = _find_pr_for_branch(branch, state="closed")
|
||||
if merged_pr and merged_pr.get("merged"):
|
||||
print(f"PR for branch {branch!r} was already merged — closing issue #{pending_issue}.")
|
||||
_close_issue(pending_issue)
|
||||
return 0
|
||||
|
||||
# No open or merged PR — the agent may not have created one, or it was
|
||||
# closed without merging (the bug this block was added to catch).
|
||||
print(
|
||||
f"No open or merged PR found for branch {branch!r} "
|
||||
f"(issue #{pending_issue}) — setting to State/Question."
|
||||
)
|
||||
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||
_comment_issue(
|
||||
pending_issue,
|
||||
f"Agent finished but no open or merged PR was found for branch `{branch}`. "
|
||||
"Please investigate and resume manually.",
|
||||
)
|
||||
return 0
|
||||
|
||||
# ── 3. Global CI check (agent pushed to main, or no pending issue) ────────
|
||||
run = _latest_ci_run()
|
||||
|
||||
@@ -528,7 +580,8 @@ def _run_loop() -> int:
|
||||
)
|
||||
return 0
|
||||
_close_issue(pending_issue)
|
||||
print(f"CI passed — closed {_issue_url(pending_issue)}.")
|
||||
ci_run_part = f" {_ci_run_url(run['id'])}" if run else ""
|
||||
print(f"CI passed{ci_run_part} — closed {_issue_url(pending_issue)}.")
|
||||
return 0
|
||||
|
||||
# Find a Ready issue.
|
||||
|
||||
+216
-11
@@ -256,21 +256,88 @@ class TestPendingCi(unittest.TestCase):
|
||||
"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 closes the issue once CI is green."""
|
||||
"""After issue agent finishes, loop merges the PR and closes the issue once CI is green."""
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "success"}), \
|
||||
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": "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_does_not_close_issue_when_ci_fails(self):
|
||||
"""After issue agent finishes, loop must NOT close the issue if CI failed."""
|
||||
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._latest_ci_run", return_value={"id": 1, "status": "failure"}), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
|
||||
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"), \
|
||||
@@ -281,7 +348,7 @@ class TestPendingCi(unittest.TestCase):
|
||||
mock_close.assert_not_called()
|
||||
|
||||
def test_saves_pending_ci_state_while_ci_running(self):
|
||||
"""When CI is still running after agent finishes, pending issue is preserved."""
|
||||
"""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):
|
||||
@@ -290,7 +357,8 @@ class TestPendingCi(unittest.TestCase):
|
||||
written["kind"] = kind
|
||||
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "running"}), \
|
||||
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()
|
||||
@@ -301,7 +369,7 @@ class TestPendingCi(unittest.TestCase):
|
||||
self.assertIsNone(written.get("pid"))
|
||||
|
||||
def test_ci_fix_preserves_pending_issue_in_state(self):
|
||||
"""When CI fails after agent finishes, ci-fix state includes the pending issue."""
|
||||
"""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):
|
||||
@@ -310,7 +378,8 @@ class TestPendingCi(unittest.TestCase):
|
||||
written["kind"] = kind
|
||||
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "failure"}), \
|
||||
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"):
|
||||
@@ -321,14 +390,17 @@ class TestPendingCi(unittest.TestCase):
|
||||
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, the pending issue is closed."""
|
||||
"""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._latest_ci_run", return_value={"id": 1, "status": "success"}), \
|
||||
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": "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):
|
||||
@@ -462,5 +534,138 @@ class TestLatestCiRunForBranch(unittest.TestCase):
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
|
||||
import 'package:sharedinbox/data/db/database.dart';
|
||||
|
||||
// Fake PathProviderPlatform that always throws PlatformException(channel-error)
|
||||
// to simulate the Pigeon channel not being ready at startup (issue #166).
|
||||
class _UnavailablePathProvider extends Fake
|
||||
with MockPlatformInterfaceMixin
|
||||
implements PathProviderPlatform {
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async {
|
||||
throw PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'Simulated: path_provider channel not ready',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fake PathProviderPlatform that fails the first [failCount] calls, then
|
||||
// returns a fixed path. Used to exercise the retry loop in
|
||||
// _resolveDatabasePath() without waiting for real timers.
|
||||
class _SucceedAfterNPathProvider extends Fake
|
||||
with MockPlatformInterfaceMixin
|
||||
implements PathProviderPlatform {
|
||||
_SucceedAfterNPathProvider({required this.failCount});
|
||||
|
||||
final int failCount;
|
||||
int _callCount = 0;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async {
|
||||
_callCount++;
|
||||
if (_callCount <= failCount) {
|
||||
throw PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'Simulated: path_provider channel not ready',
|
||||
);
|
||||
}
|
||||
return '/tmp/test_app_support';
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Regression test for https://codeberg.org/guettli/sharedinbox/issues/166:
|
||||
// On some slow Android devices the path_provider Pigeon channel is not ready
|
||||
// when initDatabasePath() runs before runApp(). initDatabasePath() must
|
||||
// absorb the PlatformException and let the app start; _resolveDatabasePath()
|
||||
// then retries with back-off on first DB access.
|
||||
test(
|
||||
'initDatabasePath completes without throwing when path_provider is unavailable',
|
||||
() async {
|
||||
final prev = PathProviderPlatform.instance;
|
||||
PathProviderPlatform.instance = _UnavailablePathProvider();
|
||||
addTearDown(() => PathProviderPlatform.instance = prev);
|
||||
|
||||
// Must not throw — the exception is swallowed so the app can continue.
|
||||
await expectLater(initDatabasePath(), completes);
|
||||
},
|
||||
);
|
||||
|
||||
// Tests for _resolveDatabasePath() — the lazy retry path called on first DB
|
||||
// access when initDatabasePath() already failed. fake_async lets us advance
|
||||
// the back-off timers without waiting real-world milliseconds.
|
||||
|
||||
test(
|
||||
'_resolveDatabasePath retries and eventually succeeds after transient failures',
|
||||
() {
|
||||
resetDatabasePathForTesting();
|
||||
final prev = PathProviderPlatform.instance;
|
||||
// Fail 3 times, succeed on the 4th call. The delays in
|
||||
// _resolveDatabasePath are [200, 500, 1000, 2000, 4000] ms, so three
|
||||
// failures cost 200+500+1000 = 1700 ms before the fourth attempt.
|
||||
PathProviderPlatform.instance = _SucceedAfterNPathProvider(failCount: 3);
|
||||
addTearDown(() {
|
||||
PathProviderPlatform.instance = prev;
|
||||
resetDatabasePathForTesting();
|
||||
});
|
||||
|
||||
fakeAsync((fake) {
|
||||
String? result;
|
||||
unawaited(resolveDatabasePathForTesting().then((r) => result = r));
|
||||
|
||||
// Advance fake time through the three back-off delays.
|
||||
fake.elapse(const Duration(milliseconds: 200 + 500 + 1000 + 1));
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result, endsWith('sharedinbox.db'));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'_resolveDatabasePath throws PlatformException after exhausting all retries',
|
||||
() {
|
||||
resetDatabasePathForTesting();
|
||||
final prev = PathProviderPlatform.instance;
|
||||
PathProviderPlatform.instance = _UnavailablePathProvider();
|
||||
addTearDown(() {
|
||||
PathProviderPlatform.instance = prev;
|
||||
resetDatabasePathForTesting();
|
||||
});
|
||||
|
||||
fakeAsync((fake) {
|
||||
Object? caughtError;
|
||||
unawaited(
|
||||
resolveDatabasePathForTesting().catchError((Object e) {
|
||||
caughtError = e;
|
||||
return ''; // ignored; satisfies the Future<String> return type
|
||||
}),
|
||||
);
|
||||
|
||||
// Advance past all five back-off delays: 200+500+1000+2000+4000 ms.
|
||||
fake.elapse(
|
||||
const Duration(milliseconds: 200 + 500 + 1000 + 2000 + 4000 + 1),
|
||||
);
|
||||
|
||||
expect(caughtError, isA<PlatformException>());
|
||||
expect(
|
||||
(caughtError! as PlatformException).message,
|
||||
contains('cannot open database'),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
@@ -76,6 +77,52 @@ void main() {
|
||||
expect(mock.launchedUrl, isNot(contains('Stack%20Trace')));
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'CrashScreen copy-to-clipboard includes version and platform info',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
String? clipboardText;
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(MethodCall call) async {
|
||||
if (call.method == 'Clipboard.setData') {
|
||||
clipboardText =
|
||||
(call.arguments as Map<dynamic, dynamic>)['text'] as String?;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||
);
|
||||
|
||||
const exception = 'TestException: clipboard test';
|
||||
final stackTrace = StackTrace.current;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: CrashScreen(exception: exception, stackTrace: stackTrace),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Copy to Clipboard'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(clipboardText, isNotNull);
|
||||
expect(clipboardText, contains('App Version: 1.0.0+42'));
|
||||
expect(clipboardText, contains('Platform:'));
|
||||
expect(clipboardText, contains('TestException: clipboard test'));
|
||||
// GIT_HASH is empty in test builds — no Git Commit line expected
|
||||
expect(clipboardText, isNot(contains('Git Commit:')));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'CrashScreen used as root widget — buttons work without ScaffoldMessenger crash',
|
||||
(tester) async {
|
||||
|
||||
Reference in New Issue
Block a user