Compare commits
42
Commits
@@ -22,6 +22,8 @@ jobs:
|
||||
- name: Detect Android and Linux changes
|
||||
id: diff
|
||||
shell: bash
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# On workflow_dispatch always build everything
|
||||
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
@@ -30,6 +32,38 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
|
||||
# Skip if this exact commit was already successfully deployed (prevents
|
||||
# hourly schedule from redeploying the same commit on every tick).
|
||||
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
|
||||
import json, os, sys, urllib.request
|
||||
token = os.environ.get("FORGEJO_TOKEN", "")
|
||||
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
url = f"{server}/api/v1/repos/{repo}/actions/runs?workflow_id=deploy.yml&status=success&limit=5"
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
data = json.loads(r.read())
|
||||
runs = [
|
||||
r for r in data.get("workflow_runs", [])
|
||||
if r.get("workflow_id") == "deploy.yml" and r.get("status") == "success"
|
||||
]
|
||||
print(runs[0]["head_sha"] if runs else "")
|
||||
except Exception as e:
|
||||
print(f"API check failed: {e}", file=sys.stderr)
|
||||
print("")
|
||||
PYEOF
|
||||
)
|
||||
|
||||
if [ -n "$LAST_DEPLOYED_SHA" ] && [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
|
||||
echo "HEAD $HEAD_SHA already successfully deployed — skipping"
|
||||
echo "android=false" >> "$GITHUB_OUTPUT"
|
||||
echo "linux=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Diff the HEAD commit against its parent; fall back to listing HEAD's files
|
||||
# when the parent is unavailable (initial commit, shallow clone).
|
||||
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|
||||
@@ -38,7 +72,7 @@ jobs:
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED"
|
||||
|
||||
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/)'
|
||||
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/|scripts/deploy_playstore\.py)'
|
||||
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
|
||||
|
||||
echo "$CHANGED" | grep -qE "$android_re" \
|
||||
@@ -97,7 +131,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-depth: 100
|
||||
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
@@ -136,7 +170,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-depth: 100
|
||||
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
@@ -156,6 +190,7 @@ jobs:
|
||||
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 }}
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
@@ -177,7 +212,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-depth: 100
|
||||
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
@@ -197,6 +232,7 @@ jobs:
|
||||
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"
|
||||
@@ -238,6 +274,7 @@ jobs:
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
name: Monitor Agent Loop
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */2 * * *' # every 2 hours
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
monitor:
|
||||
name: Check Agent Loop Health
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check agent loop heartbeat
|
||||
run: python3 scripts/agent_loop.py monitor
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Renovate
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
name: Renovate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- 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: Run Renovate
|
||||
env:
|
||||
DAGGER_NO_NAG: "1"
|
||||
RENOVATE_FORGEJO_TOKEN: ${{ secrets.RENOVATE_FORGEJO_TOKEN }}
|
||||
run: task renovate
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
@@ -13,20 +13,42 @@ jobs:
|
||||
deploy:
|
||||
name: Build & Deploy Website
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- 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: Build & 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 }}
|
||||
run: task website-deploy
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task publish-website
|
||||
|
||||
- name: Verify Website
|
||||
env:
|
||||
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
|
||||
run: scripts/website-verify.sh
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
@@ -202,6 +202,8 @@ jobs:
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
printf '%s\n' "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
|
||||
chmod 644 ~/.ssh/known_hosts
|
||||
|
||||
- name: Build Linux release
|
||||
run: |
|
||||
@@ -215,20 +217,20 @@ jobs:
|
||||
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
||||
TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz"
|
||||
tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||
scp -o StrictHostKeyChecking=no /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
|
||||
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||
scp /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
|
||||
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
|
||||
EXISTING=$(ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" \
|
||||
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" \
|
||||
"cat public_html/latest.json 2>/dev/null || echo '{}'")
|
||||
WINDOWS_URL=$(echo "$EXISTING" | \
|
||||
python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" \
|
||||
2>/dev/null || true)
|
||||
if [ -n "$WINDOWS_URL" ]; then
|
||||
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
else
|
||||
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
fi
|
||||
|
||||
- name: Generate build history pages
|
||||
@@ -244,6 +246,5 @@ jobs:
|
||||
rsync -avz --delete \
|
||||
--exclude='*.apk' \
|
||||
--exclude='*.tar.gz' \
|
||||
-e "ssh -o StrictHostKeyChecking=no" \
|
||||
website/public/ \
|
||||
"$SSH_USER@$SSH_HOST:public_html/"
|
||||
|
||||
+2
-1
@@ -28,7 +28,8 @@ android/.gradle/
|
||||
android/local.properties
|
||||
android/app/google-services.json
|
||||
android/key.properties
|
||||
android/app/src/main/java/io/flutter/plugins/
|
||||
# android/app/src/main/java/io/flutter/plugins/ intentionally tracked so that
|
||||
# GeneratedPluginRegistrant.java (catch Throwable) is committed and used by CI.
|
||||
.android/
|
||||
Android/
|
||||
.gradle/
|
||||
|
||||
@@ -10,9 +10,21 @@ CLI tool `fgj` is available to query issues/PRs/actions.
|
||||
|
||||
We use issues, follow this label state machine:
|
||||
|
||||
- **State/Ready** — Issue is available to pick up
|
||||
- **State/InProgress** — Set this when you start working on an issue
|
||||
- **State/Question** — Set this when you hit a blocker or need clarification
|
||||
- **State/ToPlan** — Issue needs a plan written by an agent before implementation
|
||||
- **State/Planned** — Plan has been posted as a comment; awaiting human review
|
||||
- **State/Ready** — Issue is approved and ready for implementation
|
||||
- **State/InProgress** — Set while an agent (or human) is actively working
|
||||
- **State/Question** — Agent hit a blocker or needs clarification
|
||||
|
||||
Full lifecycle:
|
||||
|
||||
```
|
||||
State/ToPlan → State/Planned (automated: agent_loop.py runs a planning agent)
|
||||
State/Planned → State/Ready (manual: human reviews the plan and approves)
|
||||
State/Ready → State/InProgress (automated: agent_loop.py before starting implementation)
|
||||
State/InProgress → closed (automated: after PR is merged and CI passes)
|
||||
any state → State/Question (automated or manual: when blocked)
|
||||
```
|
||||
|
||||
List open issues ready to pick up:
|
||||
|
||||
@@ -22,9 +34,11 @@ fgj issue list --json --state open | jq '[.[] | select(.labels[].name == "State/
|
||||
|
||||
Rules:
|
||||
|
||||
- Never start work on an issue without `State/Ready`
|
||||
- When working via the agent loop: `State/Ready` → `State/InProgress` is set automatically
|
||||
by `agent_loop.py` before the agent starts — do **not** set it yourself.
|
||||
- Never start implementation on an issue without `State/Ready`
|
||||
- Planning agents only post a plan comment — they do NOT write code or open PRs
|
||||
- After `State/Planned`, a human must review the plan and manually add `State/Ready`
|
||||
- When working via the agent loop: label transitions are set automatically
|
||||
by `agent_loop.py` — do **not** set them yourself.
|
||||
- When working manually: switch to `State/InProgress` as your **first action**:
|
||||
```bash
|
||||
fgj issue edit <NUMBER> --remove-label "State/Ready" --add-label "State/InProgress"
|
||||
|
||||
+52
-18
@@ -215,14 +215,16 @@ tasks:
|
||||
preconditions:
|
||||
- sh: test -n "$SSH_PRIVATE_KEY"
|
||||
msg: "SSH_PRIVATE_KEY is not set"
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
cmds:
|
||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
||||
|
||||
build-android-bundle:
|
||||
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
||||
cmds:
|
||||
- mkdir -p build/app/outputs/bundle/release
|
||||
- dagger call --progress=plain -q -m ci --source=. build-android-release -o build/app/outputs/bundle/release/app-release.aab
|
||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. build-android-release --commit-hash "$HASH" -o build/app/outputs/bundle/release/app-release.aab
|
||||
|
||||
upload-android-bundle:
|
||||
desc: Upload AAB from build/ to Play Store via Dagger
|
||||
@@ -236,6 +238,7 @@ tasks:
|
||||
|
||||
publish-android:
|
||||
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
|
||||
deps: [generate-changelog]
|
||||
preconditions:
|
||||
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
|
||||
msg: "PLAY_STORE_CONFIG_JSON is not set"
|
||||
@@ -244,24 +247,31 @@ tasks:
|
||||
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||
cmds:
|
||||
- dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD
|
||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
|
||||
|
||||
deploy-apk:
|
||||
desc: Build and deploy Android APK via Dagger
|
||||
preconditions:
|
||||
- sh: test -n "$SSH_PRIVATE_KEY"
|
||||
msg: "SSH_PRIVATE_KEY is not set"
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
- sh: test -n "$ANDROID_KEYSTORE_BASE64"
|
||||
msg: "ANDROID_KEYSTORE_BASE64 is not set"
|
||||
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||
cmds:
|
||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
|
||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
|
||||
|
||||
publish-website:
|
||||
desc: Build and publish website via Dagger
|
||||
preconditions:
|
||||
- sh: test -n "$SSH_PRIVATE_KEY"
|
||||
msg: "SSH_PRIVATE_KEY is not set"
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
cmds:
|
||||
- dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key file:$HOME/.ssh/id_ed25519 --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST"
|
||||
- dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST"
|
||||
|
||||
check-dagger:
|
||||
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
|
||||
@@ -326,6 +336,14 @@ tasks:
|
||||
- |
|
||||
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }'
|
||||
|
||||
renovate:
|
||||
desc: Run Renovate bot against the repository via Dagger
|
||||
preconditions:
|
||||
- sh: test -n "$RENOVATE_FORGEJO_TOKEN"
|
||||
msg: "RENOVATE_FORGEJO_TOKEN is not set"
|
||||
cmds:
|
||||
- dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
|
||||
|
||||
integration-android:
|
||||
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
||||
deps: [_preflight, _android-sdk-check, _android-avd-setup]
|
||||
@@ -373,25 +391,29 @@ tasks:
|
||||
msg: "SSH_USER is not set"
|
||||
- sh: test -n "$SSH_HOST"
|
||||
msg: "SSH_HOST is not set"
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
cmds:
|
||||
- |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||
HASH=$(git rev-parse --short HEAD)
|
||||
DATE_PATH=$(date -u +%Y/%m/%d)
|
||||
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
||||
TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz"
|
||||
tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||
scp -o StrictHostKeyChecking=no /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
|
||||
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||
scp /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
|
||||
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
|
||||
# Merge with any existing latest.json so we don't overwrite the windows key
|
||||
EXISTING=$(ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
|
||||
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
|
||||
WINDOWS_URL=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" 2>/dev/null || true)
|
||||
if [ -n "$WINDOWS_URL" ]; then
|
||||
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
else
|
||||
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
fi
|
||||
echo "Uploaded $TARBALL and updated latest.json"
|
||||
|
||||
@@ -416,24 +438,28 @@ tasks:
|
||||
msg: "SSH_USER is not set"
|
||||
- sh: test -n "$SSH_HOST"
|
||||
msg: "SSH_HOST is not set"
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
cmds:
|
||||
- |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||
HASH=$(git rev-parse --short HEAD)
|
||||
DATE_PATH=$(date -u +%Y/%m/%d)
|
||||
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
||||
ZIPFILE="sharedinbox-windows-x64-$HASH.zip"
|
||||
cd build/windows/x64/runner && zip -r /tmp/$ZIPFILE Release/ && cd -
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||
scp -o StrictHostKeyChecking=no /tmp/$ZIPFILE "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$ZIPFILE"
|
||||
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||
scp /tmp/$ZIPFILE "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$ZIPFILE"
|
||||
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$ZIPFILE"
|
||||
EXISTING=$(ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
|
||||
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
|
||||
LINUX_URL=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('linux',''))" 2>/dev/null || true)
|
||||
if [ -n "$LINUX_URL" ]; then
|
||||
echo "{\"version\":\"$HASH\",\"linux\":\"$LINUX_URL\",\"windows\":\"$DOWNLOAD_URL\"}" | \
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
else
|
||||
echo "{\"version\":\"$HASH\",\"windows\":\"$DOWNLOAD_URL\"}" | \
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
fi
|
||||
echo "Uploaded $ZIPFILE and updated latest.json"
|
||||
|
||||
@@ -583,14 +609,18 @@ tasks:
|
||||
msg: "SSH_USER is not set"
|
||||
- sh: test -n "$SSH_HOST"
|
||||
msg: "SSH_HOST is not set"
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
cmds:
|
||||
- |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||
HASH=$(git rev-parse --short HEAD)
|
||||
DATE_PATH=$(date -u +%Y/%m/%d)
|
||||
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
||||
APK_NAME="sharedinbox-mua-$HASH.apk"
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||
scp -o StrictHostKeyChecking=no \
|
||||
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||
scp \
|
||||
build/app/outputs/flutter-apk/app-release.apk \
|
||||
"$SSH_USER@$SSH_HOST:$REMOTE_DIR/$APK_NAME"
|
||||
echo "Uploaded $APK_NAME to $REMOTE_DIR"
|
||||
@@ -619,12 +649,16 @@ tasks:
|
||||
website-deploy:
|
||||
desc: Deploy the website via rsync to public_html
|
||||
deps: [website-build]
|
||||
preconditions:
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
cmds:
|
||||
- |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||
rsync -avz --delete \
|
||||
--exclude='*.apk' \
|
||||
--exclude='*.tar.gz' \
|
||||
-e "ssh -o StrictHostKeyChecking=no" \
|
||||
website/public/ \
|
||||
${SSH_USER}@${SSH_HOST}:public_html/
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ gradle-wrapper.jar
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package io.flutter.plugins;
|
||||
|
||||
import androidx.annotation.Keep;
|
||||
import androidx.annotation.NonNull;
|
||||
import io.flutter.Log;
|
||||
|
||||
import io.flutter.embedding.engine.FlutterEngine;
|
||||
|
||||
/**
|
||||
* Generated file. Do not edit.
|
||||
* This file is generated by the Flutter tool based on the
|
||||
* plugins that support the Android platform.
|
||||
*/
|
||||
@Keep
|
||||
public final class GeneratedPluginRegistrant {
|
||||
private static final String TAG = "GeneratedPluginRegistrant";
|
||||
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin device_info_plus, dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new dev.flutter.plugins.integration_test.IntegrationTestPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin integration_test, dev.flutter.plugins.integration_test.IntegrationTestPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new dev.steenbakker.mobile_scanner.MobileScannerPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin mobile_scanner, dev.steenbakker.mobile_scanner.MobileScannerPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.crazecoder.openfile.OpenFilePlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin open_filex, com.crazecoder.openfile.OpenFilePlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.share.SharePlusPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin share_plus, dev.fluttercommunity.plus.share.SharePlusPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.webviewflutter.WebViewFlutterPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin webview_flutter_android, io.flutter.plugins.webviewflutter.WebViewFlutterPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new dev.fluttercommunity.workmanager.WorkmanagerPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin workmanager_android, dev.fluttercommunity.workmanager.WorkmanagerPlugin", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
-27
@@ -195,7 +195,8 @@ func (m *Ci) toolchain() *dagger.Container {
|
||||
WithUser("ci").
|
||||
WithExec([]string{"/bin/sh", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`})
|
||||
`yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`}).
|
||||
WithExec([]string{"flutter", "precache", "--linux", "--no-android", "--no-ios"})
|
||||
}
|
||||
|
||||
// Base is the Flutter toolchain container with mutable cache mounts attached.
|
||||
@@ -285,6 +286,21 @@ func (m *Ci) firebaseSrc() *dagger.Directory {
|
||||
})
|
||||
}
|
||||
|
||||
// androidBase wraps setup(androidSrc()) with the Gradle named-cache so that
|
||||
// Gradle dependencies survive across Dagger execution-cache misses.
|
||||
func (m *Ci) androidBase() *dagger.Container {
|
||||
return m.setup(m.androidSrc()).
|
||||
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
|
||||
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
|
||||
}
|
||||
|
||||
// firebaseBase wraps setup(firebaseSrc()) with the Gradle named-cache.
|
||||
func (m *Ci) firebaseBase() *dagger.Container {
|
||||
return m.setup(m.firebaseSrc()).
|
||||
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
|
||||
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
|
||||
}
|
||||
|
||||
// linuxSrc is the source subset for Linux builds and integration tests.
|
||||
func (m *Ci) linuxSrc() *dagger.Directory {
|
||||
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
@@ -318,12 +334,13 @@ func (m *Ci) Hugo() *dagger.Container {
|
||||
}
|
||||
|
||||
// Deploy container for rsync/ssh
|
||||
func (m *Ci) Deployer(sshKey *dagger.Secret) *dagger.Container {
|
||||
func (m *Ci) Deployer(sshKey *dagger.Secret, knownHosts *dagger.Secret) *dagger.Container {
|
||||
return dag.Container().
|
||||
From("alpine:3.21").
|
||||
WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}).
|
||||
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
||||
WithEnvVariable("RSYNC_RSH", "ssh -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519")
|
||||
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
|
||||
WithEnvVariable("RSYNC_RSH", "ssh -i /root/.ssh/id_ed25519")
|
||||
}
|
||||
|
||||
// Stalwart mail server service for backend and integration tests.
|
||||
@@ -514,6 +531,7 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
|
||||
func (m *Ci) GenerateBuildHistory(
|
||||
ctx context.Context,
|
||||
sshKey *dagger.Secret,
|
||||
knownHosts *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
) *dagger.Directory {
|
||||
@@ -525,7 +543,7 @@ func (m *Ci) GenerateBuildHistory(
|
||||
From("python:3.12-alpine").
|
||||
WithExec([]string{"apk", "add", "--no-cache", "openssh-client"}).
|
||||
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
||||
WithExec([]string{"chmod", "700", "/root/.ssh"}).
|
||||
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
|
||||
WithEnvVariable("SSH_USER", sshUser).
|
||||
WithEnvVariable("SSH_HOST", sshHost).
|
||||
WithDirectory("/src", scriptSource).
|
||||
@@ -538,10 +556,11 @@ func (m *Ci) GenerateBuildHistory(
|
||||
func (m *Ci) BuildWebsite(
|
||||
ctx context.Context,
|
||||
sshKey *dagger.Secret,
|
||||
knownHosts *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
) *dagger.Directory {
|
||||
buildHistory := m.GenerateBuildHistory(ctx, sshKey, sshUser, sshHost)
|
||||
buildHistory := m.GenerateBuildHistory(ctx, sshKey, knownHosts, sshUser, sshHost)
|
||||
|
||||
websiteSource := m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"website/"},
|
||||
@@ -558,12 +577,13 @@ func (m *Ci) BuildWebsite(
|
||||
func (m *Ci) PublishWebsite(
|
||||
ctx context.Context,
|
||||
sshKey *dagger.Secret,
|
||||
knownHosts *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
) (string, error) {
|
||||
public := m.BuildWebsite(ctx, sshKey, sshUser, sshHost)
|
||||
public := m.BuildWebsite(ctx, sshKey, knownHosts, sshUser, sshHost)
|
||||
|
||||
return m.Deployer(sshKey).
|
||||
return m.Deployer(sshKey, knownHosts).
|
||||
WithDirectory("/public", public).
|
||||
WithExec([]string{"rsync", "-avz", "--delete",
|
||||
"--exclude=*.apk", "--exclude=*.tar.gz",
|
||||
@@ -579,9 +599,17 @@ func (m *Ci) BuildLinux() *dagger.Directory {
|
||||
}
|
||||
|
||||
// BuildLinuxRelease builds the Linux release bundle.
|
||||
func (m *Ci) BuildLinuxRelease() *dagger.Directory {
|
||||
func (m *Ci) BuildLinuxRelease(
|
||||
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
||||
// +optional
|
||||
commitHash string,
|
||||
) *dagger.Directory {
|
||||
args := []string{"flutter", "build", "linux", "--release"}
|
||||
if commitHash != "" {
|
||||
args = append(args, "--dart-define=GIT_HASH="+commitHash)
|
||||
}
|
||||
return m.setup(m.linuxSrc()).
|
||||
WithExec([]string{"flutter", "build", "linux", "--release"}).
|
||||
WithExec(args).
|
||||
Directory("build/linux/x64/release/bundle")
|
||||
}
|
||||
|
||||
@@ -589,36 +617,48 @@ func (m *Ci) BuildLinuxRelease() *dagger.Directory {
|
||||
func (m *Ci) DeployLinux(
|
||||
ctx context.Context,
|
||||
sshKey *dagger.Secret,
|
||||
knownHosts *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
commitHash string,
|
||||
) (string, error) {
|
||||
bundle := m.BuildLinuxRelease()
|
||||
bundle := m.BuildLinuxRelease(commitHash)
|
||||
|
||||
datePath := time.Now().Format("2006/01/02")
|
||||
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
||||
tarball := fmt.Sprintf("sharedinbox-linux-amd64-%s.tar.gz", commitHash)
|
||||
|
||||
return m.Deployer(sshKey).
|
||||
return m.Deployer(sshKey, knownHosts).
|
||||
WithDirectory("/bundle", bundle).
|
||||
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("tar -czf /tmp/%s -C /bundle .", tarball)}).
|
||||
WithExec([]string{"ssh", "-o", "StrictHostKeyChecking=no", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
|
||||
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519 /tmp/%s %s@%s:%s/%s", tarball, sshUser, sshHost, remoteDir, tarball)}).
|
||||
WithExec([]string{"ssh", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
|
||||
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -i /root/.ssh/id_ed25519 /tmp/%s %s@%s:%s/%s", tarball, sshUser, sshHost, remoteDir, tarball)}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// setupKeystore decodes the base64 keystore into the android build container.
|
||||
func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container {
|
||||
return m.setup(m.androidSrc()).
|
||||
return m.androidBase().
|
||||
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
|
||||
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
|
||||
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`})
|
||||
}
|
||||
|
||||
// BuildAndroidApk builds a release APK signed with the upload key.
|
||||
func (m *Ci) BuildAndroidApk(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret, buildNumber string) *dagger.File {
|
||||
func (m *Ci) BuildAndroidApk(
|
||||
keystoreBase64 *dagger.Secret,
|
||||
keystorePassword *dagger.Secret,
|
||||
buildNumber string,
|
||||
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
||||
// +optional
|
||||
commitHash string,
|
||||
) *dagger.File {
|
||||
args := []string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}
|
||||
if commitHash != "" {
|
||||
args = append(args, "--dart-define=GIT_HASH="+commitHash)
|
||||
}
|
||||
return m.setupKeystore(keystoreBase64, keystorePassword).
|
||||
WithExec([]string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}).
|
||||
WithExec(args).
|
||||
File("build/app/outputs/flutter-apk/app-release.apk")
|
||||
}
|
||||
|
||||
@@ -626,6 +666,7 @@ func (m *Ci) BuildAndroidApk(keystoreBase64 *dagger.Secret, keystorePassword *da
|
||||
func (m *Ci) DeployApk(
|
||||
ctx context.Context,
|
||||
sshKey *dagger.Secret,
|
||||
knownHosts *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
commitHash string,
|
||||
@@ -633,24 +674,23 @@ func (m *Ci) DeployApk(
|
||||
keystorePassword *dagger.Secret,
|
||||
buildNumber string,
|
||||
) (string, error) {
|
||||
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber)
|
||||
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber, commitHash)
|
||||
|
||||
datePath := time.Now().Format("2006/01/02")
|
||||
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
||||
apkName := fmt.Sprintf("sharedinbox-mua-%s.apk", commitHash)
|
||||
|
||||
return m.Deployer(sshKey).
|
||||
return m.Deployer(sshKey, knownHosts).
|
||||
WithFile("/tmp/app.apk", apk).
|
||||
WithExec([]string{"ssh", "-o", "StrictHostKeyChecking=no", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
|
||||
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519 /tmp/app.apk %s@%s:%s/%s", sshUser, sshHost, remoteDir, apkName)}).
|
||||
WithExec([]string{"ssh", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
|
||||
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -i /root/.ssh/id_ed25519 /tmp/app.apk %s@%s:%s/%s", sshUser, sshHost, remoteDir, apkName)}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// BuildAndroidDebugApks builds the debug app APK and the androidTest APK needed for Firebase Test Lab.
|
||||
// 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"}).
|
||||
built := m.firebaseBase().
|
||||
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
|
||||
WithWorkdir("/src/android").
|
||||
// --no-daemon avoids connecting to a stale daemon whose registry file was
|
||||
@@ -709,9 +749,17 @@ func (m *Ci) TestAndroidFirebase(
|
||||
|
||||
// BuildAndroidRelease builds the AAB with a fixed build-number so Dagger can cache it.
|
||||
// versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle.
|
||||
func (m *Ci) BuildAndroidRelease() *dagger.File {
|
||||
return m.setup(m.androidSrc()).
|
||||
WithExec([]string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}).
|
||||
func (m *Ci) BuildAndroidRelease(
|
||||
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
||||
// +optional
|
||||
commitHash string,
|
||||
) *dagger.File {
|
||||
args := []string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}
|
||||
if commitHash != "" {
|
||||
args = append(args, "--dart-define=GIT_HASH="+commitHash)
|
||||
}
|
||||
return m.androidBase().
|
||||
WithExec(args).
|
||||
File("build/app/outputs/bundle/release/app-release.aab")
|
||||
}
|
||||
|
||||
@@ -783,14 +831,30 @@ func (m *Ci) PublishAndroid(
|
||||
playStoreConfig *dagger.Secret,
|
||||
keystoreBase64 *dagger.Secret,
|
||||
keystorePassword *dagger.Secret,
|
||||
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
||||
// +optional
|
||||
commitHash string,
|
||||
) (string, error) {
|
||||
versionCode := int(time.Now().Unix())
|
||||
aab := m.BuildAndroidRelease()
|
||||
aab := m.BuildAndroidRelease(commitHash)
|
||||
stamped := m.StampAndroidVersionCode(aab, versionCode)
|
||||
signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword)
|
||||
return m.UploadToPlayStore(ctx, signed, playStoreConfig)
|
||||
}
|
||||
|
||||
// Renovate runs Renovate bot against the repository on Forgejo/Codeberg.
|
||||
func (m *Ci) Renovate(ctx context.Context, renovateToken *dagger.Secret) (string, error) {
|
||||
return dag.Container().
|
||||
From("renovate/renovate:39").
|
||||
WithSecretVariable("RENOVATE_TOKEN", renovateToken).
|
||||
WithEnvVariable("RENOVATE_PLATFORM", "forgejo").
|
||||
WithEnvVariable("RENOVATE_ENDPOINT", "https://codeberg.org").
|
||||
WithEnvVariable("RENOVATE_REPOSITORIES", "guettli/sharedinbox").
|
||||
WithEnvVariable("LOG_LEVEL", "info").
|
||||
WithExec([]string{"renovate"}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// Graph returns a Mermaid diagram of the CI pipeline structure.
|
||||
// Paste the output into any Mermaid renderer (codeberg, github, mermaid.live)
|
||||
// or save it as a .md file to get a rendered diagram.
|
||||
@@ -804,7 +868,7 @@ func (m *Ci) Graph() string {
|
||||
` + "```" + `mermaid
|
||||
flowchart TD
|
||||
subgraph dagger ["Dagger · Check pipeline"]
|
||||
toolchain["toolchain\nflutter:3.41.6 + NDK + apt"]
|
||||
toolchain["toolchain\nflutter:3.41.6 + NDK + apt + precache"]
|
||||
pubGet["pubGetLayer\nflutter pub get"]
|
||||
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
|
||||
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
|
||||
|
||||
@@ -13,7 +13,7 @@ export SSH_PRIVATE_KEY=$(cat "$HOME/.ssh/id_ed25519")
|
||||
|
||||
# Add nix profile and nix store tools (task, dagger) to PATH
|
||||
export PATH="$HOME/.nix-profile/bin:$PATH"
|
||||
for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger"; do
|
||||
for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger" "*fgj-*/bin/fgj"; do
|
||||
bin=$(ls -d /nix/store/$pkg 2>/dev/null | sort -V | tail -1)
|
||||
[ -n "$bin" ] && export PATH="$(dirname "$bin"):$PATH"
|
||||
done
|
||||
|
||||
@@ -4,6 +4,16 @@ This file contains tasks which got implemented.
|
||||
|
||||
Tasks get moved from next.md to done.md
|
||||
|
||||
## Tasks (2026-05-26)
|
||||
|
||||
- **Renovate Bot (Issue #257)**: Renovate Bot runs daily via Forgejo Actions to keep
|
||||
dependencies up to date. All required components are in main:
|
||||
- `renovate.json` — Renovate configuration covering pub, Dockerfile, and Forgejo Actions
|
||||
- `ci/main.go` — `Renovate()` Dagger function using Forgejo platform and Codeberg endpoint
|
||||
- `.forgejo/workflows/renovate.yml` — daily cron (06:00 UTC) workflow
|
||||
- `Taskfile.yml` — `renovate` task
|
||||
- Issue #257 closed.
|
||||
|
||||
## Tasks (2026-05-11)
|
||||
|
||||
- **Stabilize Email List UI during Selection (Issue #14)**: Prevented layout shifts when entering
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
const int dbSchemaVersion = 33;
|
||||
@@ -20,7 +20,7 @@ class SyncLogEntry {
|
||||
required this.result,
|
||||
this.errorMessage,
|
||||
this.stackTrace,
|
||||
this.isPermanent,
|
||||
this.isPermanent = false,
|
||||
required this.protocol,
|
||||
required this.emailsFetched,
|
||||
required this.emailsSkipped,
|
||||
@@ -37,7 +37,7 @@ class SyncLogEntry {
|
||||
final String result; // 'ok' or 'error'
|
||||
final String? errorMessage;
|
||||
final String? stackTrace;
|
||||
final bool? isPermanent;
|
||||
final bool isPermanent;
|
||||
final String protocol; // 'imap' or 'jmap'
|
||||
final int emailsFetched;
|
||||
final int emailsSkipped;
|
||||
@@ -59,7 +59,7 @@ abstract class SyncLogRepository {
|
||||
required bool success,
|
||||
String? errorMessage,
|
||||
String? stackTrace,
|
||||
bool? isPermanent,
|
||||
bool isPermanent = false,
|
||||
required String protocol,
|
||||
required int emailsFetched,
|
||||
required int emailsSkipped,
|
||||
@@ -88,7 +88,7 @@ class NoOpSyncLogRepository implements SyncLogRepository {
|
||||
required bool success,
|
||||
String? errorMessage,
|
||||
String? stackTrace,
|
||||
bool? isPermanent,
|
||||
bool isPermanent = false,
|
||||
required String protocol,
|
||||
required int emailsFetched,
|
||||
required int emailsSkipped,
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:drift/native.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sharedinbox/core/db_schema_version.dart';
|
||||
|
||||
part 'database.g.dart';
|
||||
|
||||
@@ -192,10 +193,9 @@ class SyncLogs extends Table {
|
||||
DateTimeColumn get finishedAt => dateTime()();
|
||||
// Added in schema v13: raw protocol log when account.verbose == true.
|
||||
TextColumn get protocolLog => text().nullable()();
|
||||
// Added in schema v33: stack trace captured when an error occurs.
|
||||
TextColumn get stackTrace => text().nullable()();
|
||||
// Added in schema v33: whether the sync loop stopped permanently after this error.
|
||||
BoolColumn get isPermanent => boolean().nullable()();
|
||||
// Added in schema v33: stack trace and permanent flag for error entries.
|
||||
TextColumn get errorStackTrace => text().nullable()();
|
||||
BoolColumn get isPermanent => boolean().withDefault(const Constant(false))();
|
||||
}
|
||||
|
||||
/// Per-mailbox breakdown for a single sync cycle.
|
||||
@@ -333,7 +333,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 33;
|
||||
int get schemaVersion => dbSchemaVersion;
|
||||
|
||||
Future<void> _createEmailFts() async {
|
||||
await customStatement('''
|
||||
@@ -575,7 +575,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
await m.createTable(localSieveApplied);
|
||||
}
|
||||
if (from >= 7 && from < 33) {
|
||||
await m.addColumn(syncLogs, syncLogs.stackTrace);
|
||||
await m.addColumn(syncLogs, syncLogs.errorStackTrace);
|
||||
await m.addColumn(syncLogs, syncLogs.isPermanent);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
||||
required bool success,
|
||||
String? errorMessage,
|
||||
String? stackTrace,
|
||||
bool? isPermanent,
|
||||
bool isPermanent = false,
|
||||
required String protocol,
|
||||
required int emailsFetched,
|
||||
required int emailsSkipped,
|
||||
@@ -32,7 +32,7 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
||||
accountId: accountId,
|
||||
result: success ? 'ok' : 'error',
|
||||
errorMessage: Value(errorMessage),
|
||||
stackTrace: Value(stackTrace),
|
||||
errorStackTrace: Value(stackTrace),
|
||||
isPermanent: Value(isPermanent),
|
||||
protocol: Value(protocol),
|
||||
itemsSynced: Value(emailsFetched),
|
||||
@@ -79,7 +79,7 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
||||
id: r.id,
|
||||
result: r.result,
|
||||
errorMessage: r.errorMessage,
|
||||
stackTrace: r.stackTrace,
|
||||
stackTrace: r.errorStackTrace,
|
||||
isPermanent: r.isPermanent,
|
||||
protocol: r.protocol,
|
||||
emailsFetched: r.itemsSynced,
|
||||
|
||||
@@ -19,14 +19,20 @@ class AboutScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
|
||||
late final Future<Map<String, String>> _deviceInfoFuture =
|
||||
fetchAndroidDeviceInfo();
|
||||
late final Future<String?> _deviceModelFuture;
|
||||
late final Stream<List<Account>> _accountsStream;
|
||||
String? _deviceModel;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
|
||||
_deviceModelFuture = getDeviceModel();
|
||||
unawaited(
|
||||
_deviceModelFuture.then((model) {
|
||||
if (mounted) setState(() => _deviceModel = model);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _copyToClipboard(
|
||||
@@ -38,12 +44,20 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
try {
|
||||
pkg = await _packageInfoFuture;
|
||||
} catch (_) {}
|
||||
final deviceInfo = await _deviceInfoFuture;
|
||||
String? deviceModel;
|
||||
try {
|
||||
deviceModel = await _deviceModelFuture;
|
||||
} catch (_) {}
|
||||
if (!context.mounted) return;
|
||||
await Clipboard.setData(
|
||||
ClipboardData(
|
||||
text:
|
||||
buildAboutMarkdown(context, pkg, imapCount, jmapCount, deviceInfo),
|
||||
text: buildAboutMarkdown(
|
||||
context: context,
|
||||
pkg: pkg,
|
||||
imapCount: imapCount,
|
||||
jmapCount: jmapCount,
|
||||
deviceModel: deviceModel,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (context.mounted) {
|
||||
@@ -56,6 +70,30 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _launchUrl(BuildContext context, Uri url) async {
|
||||
try {
|
||||
final launched =
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
if (!launched && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('Could not open browser.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text('Error: $e'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createIssue(
|
||||
BuildContext context,
|
||||
int imapCount,
|
||||
@@ -65,10 +103,19 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
try {
|
||||
pkg = await _packageInfoFuture;
|
||||
} catch (_) {}
|
||||
final deviceInfo = await _deviceInfoFuture;
|
||||
String? deviceModel;
|
||||
try {
|
||||
deviceModel = await _deviceModelFuture;
|
||||
} catch (_) {}
|
||||
if (!context.mounted) return;
|
||||
final body = Uri.encodeComponent(
|
||||
buildAboutMarkdown(context, pkg, imapCount, jmapCount, deviceInfo),
|
||||
buildAboutMarkdown(
|
||||
context: context,
|
||||
pkg: pkg,
|
||||
imapCount: imapCount,
|
||||
jmapCount: jmapCount,
|
||||
deviceModel: deviceModel,
|
||||
),
|
||||
);
|
||||
final url = Uri.parse(
|
||||
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
|
||||
@@ -112,40 +159,25 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FutureBuilder<(PackageInfo?, Map<String, String>)>(
|
||||
future: Future.wait([
|
||||
_packageInfoFuture.then<PackageInfo?>((p) => p).catchError(
|
||||
(_) => null,
|
||||
),
|
||||
_deviceInfoFuture,
|
||||
]).then(
|
||||
(results) => (
|
||||
results[0] as PackageInfo?,
|
||||
results[1] as Map<String, String>,
|
||||
),
|
||||
),
|
||||
child: FutureBuilder<PackageInfo>(
|
||||
future: _packageInfoFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final pkg = snapshot.data?.$1;
|
||||
final deviceInfo = snapshot.data?.$2 ?? {};
|
||||
return Markdown(
|
||||
data: buildAboutMarkdown(
|
||||
context,
|
||||
pkg,
|
||||
imapCount,
|
||||
jmapCount,
|
||||
deviceInfo,
|
||||
context: context,
|
||||
pkg: snapshot.data,
|
||||
imapCount: imapCount,
|
||||
jmapCount: jmapCount,
|
||||
deviceModel: _deviceModel,
|
||||
),
|
||||
selectable: true,
|
||||
onTapLink: (text, href, title) {
|
||||
if (href != null) {
|
||||
unawaited(
|
||||
launchUrl(
|
||||
Uri.parse(href),
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
_launchUrl(context, Uri.parse(href)),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -32,11 +32,15 @@ enum _Step { generatingKey, showingPubKey, scanning, importing, done, error }
|
||||
class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||
_Step _step = _Step.generatingKey;
|
||||
ShareKeyMaterial? _keyMaterial;
|
||||
DateTime? _keyExpiresAt;
|
||||
String? _pubKeyQr;
|
||||
String? _errorMessage;
|
||||
bool _scannerActive = false;
|
||||
|
||||
MobileScannerController? _scannerController;
|
||||
// True when the scanner plugin fails to initialise at runtime (e.g.
|
||||
// MissingPluginException on some Android builds).
|
||||
bool _scannerFailed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -61,6 +65,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||
);
|
||||
setState(() {
|
||||
_keyMaterial = material;
|
||||
_keyExpiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
|
||||
_pubKeyQr = qr;
|
||||
_step = _Step.showingPubKey;
|
||||
});
|
||||
@@ -76,8 +81,37 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||
setState(() {
|
||||
_step = _Step.scanning;
|
||||
_scannerActive = true;
|
||||
_scannerController = MobileScannerController();
|
||||
});
|
||||
if (_cameraScanSupported()) {
|
||||
unawaited(_initScanner());
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-flight: probe the scanner's permission-state method to verify the
|
||||
// plugin is registered. MissingPluginException is thrown on Android builds
|
||||
// where the plugin is not linked (issue #204). All other exceptions mean
|
||||
// the plugin exists but something else failed — the MobileScanner widget
|
||||
// will surface those via its own error builder.
|
||||
Future<void> _initScanner() async {
|
||||
bool available = false;
|
||||
try {
|
||||
await const MethodChannel(
|
||||
'dev.steenbakker.mobile_scanner/scanner/method',
|
||||
).invokeMethod<int>('state');
|
||||
available = true;
|
||||
} on MissingPluginException {
|
||||
// Plugin not registered on this device; text fallback will be shown.
|
||||
} catch (_) {
|
||||
// Plugin registered but state check failed; let the scanner widget
|
||||
// handle it via its errorBuilder.
|
||||
available = true;
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (available) {
|
||||
setState(() => _scannerController = MobileScannerController());
|
||||
} else {
|
||||
setState(() => _scannerFailed = true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onScanned(String rawValue) async {
|
||||
@@ -244,7 +278,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const _ExpiryHint(),
|
||||
_ExpiryHint(expiresAt: _keyExpiresAt!),
|
||||
const SizedBox(height: 32),
|
||||
if (_errorMessage != null) ...[
|
||||
Text(
|
||||
@@ -266,11 +300,14 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||
}
|
||||
|
||||
Widget _buildScannerView(BuildContext context) {
|
||||
// On platforms where the camera scanner is not available (Linux desktop),
|
||||
// fall back to a text-input field.
|
||||
if (!_cameraScanSupported()) {
|
||||
// Fall back to text input when the platform has no camera support or when
|
||||
// the scanner plugin fails to initialise at runtime (MissingPluginException).
|
||||
if (!_cameraScanSupported() || _scannerFailed) {
|
||||
return _buildTextFallbackView(context);
|
||||
}
|
||||
if (_scannerController == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
@@ -371,8 +408,37 @@ bool _cameraScanSupported() =>
|
||||
Platform.isMacOS ||
|
||||
Platform.isWindows;
|
||||
|
||||
class _ExpiryHint extends StatelessWidget {
|
||||
const _ExpiryHint();
|
||||
class _ExpiryHint extends StatefulWidget {
|
||||
const _ExpiryHint({required this.expiresAt});
|
||||
|
||||
final DateTime expiresAt;
|
||||
|
||||
@override
|
||||
State<_ExpiryHint> createState() => _ExpiryHintState();
|
||||
}
|
||||
|
||||
class _ExpiryHintState extends State<_ExpiryHint> {
|
||||
late Timer _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _formatRemaining() {
|
||||
final remaining = widget.expiresAt.difference(DateTime.now().toUtc());
|
||||
if (remaining.isNegative) return 'expired';
|
||||
final minutes = remaining.inMinutes;
|
||||
final seconds = remaining.inSeconds % 60;
|
||||
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -382,7 +448,7 @@ class _ExpiryHint extends StatelessWidget {
|
||||
Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'This key expires in 20 minutes',
|
||||
'This key expires in ${_formatRemaining()}',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -45,12 +45,42 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
||||
bool _scannerActive = true;
|
||||
|
||||
MobileScannerController? _scannerController;
|
||||
// True when the scanner plugin fails to initialise at runtime (e.g.
|
||||
// MissingPluginException on some Android builds).
|
||||
bool _scannerFailed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (_cameraScanSupported()) {
|
||||
_scannerController = MobileScannerController();
|
||||
unawaited(_initScanner());
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-flight: probe the scanner's permission-state method to verify the
|
||||
// plugin is registered. MissingPluginException is thrown on Android builds
|
||||
// where the plugin is not linked (issue #204). All other exceptions mean
|
||||
// the plugin exists but something else failed — the MobileScanner widget
|
||||
// will surface those via its own error builder.
|
||||
Future<void> _initScanner() async {
|
||||
bool available = false;
|
||||
try {
|
||||
await const MethodChannel(
|
||||
'dev.steenbakker.mobile_scanner/scanner/method',
|
||||
).invokeMethod<int>('state');
|
||||
available = true;
|
||||
} on MissingPluginException {
|
||||
// Plugin not registered on this device; text fallback will be shown.
|
||||
} catch (_) {
|
||||
// Plugin registered but state check failed; let the scanner widget
|
||||
// handle it via its errorBuilder.
|
||||
available = true;
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (available) {
|
||||
setState(() => _scannerController = MobileScannerController());
|
||||
} else {
|
||||
setState(() => _scannerFailed = true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,9 +208,12 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
||||
}
|
||||
|
||||
Widget _buildScanStep(BuildContext context) {
|
||||
if (!_cameraScanSupported()) {
|
||||
if (!_cameraScanSupported() || _scannerFailed) {
|
||||
return _buildTextFallbackView(context);
|
||||
}
|
||||
if (_scannerController == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
@@ -17,20 +18,38 @@ class CrashScreen extends StatelessWidget {
|
||||
final StackTrace? stackTrace;
|
||||
final String gitHash;
|
||||
|
||||
Future<String> _buildReport() async {
|
||||
String version = 'unknown';
|
||||
String get _buildMode {
|
||||
if (kDebugMode) return 'debug';
|
||||
if (kProfileMode) return 'profile';
|
||||
return 'release';
|
||||
}
|
||||
|
||||
Future<String> _fetchVersion() async {
|
||||
try {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
version = '${info.version}+${info.buildNumber}';
|
||||
} catch (_) {}
|
||||
return '${info.version}+${info.buildNumber}';
|
||||
} catch (_) {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _buildReport() async {
|
||||
final version = await _fetchVersion();
|
||||
final platform =
|
||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
||||
final versionDisplay = gitHash.isNotEmpty
|
||||
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)'
|
||||
: version;
|
||||
final gitLine = gitHash.isNotEmpty
|
||||
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
|
||||
: '';
|
||||
return 'App Version: $version\n'
|
||||
final timestamp = DateTime.now().toUtc().toIso8601String();
|
||||
return 'App Version: $versionDisplay\n'
|
||||
'Build Mode: $_buildMode\n'
|
||||
'$gitLine'
|
||||
'Platform: $platform\n\n'
|
||||
'Platform: $platform\n'
|
||||
'Dart: ${Platform.version}\n'
|
||||
'Timestamp: $timestamp\n\n'
|
||||
'Error:\n```\n$exception\n```\n\n'
|
||||
'Stack Trace:\n```\n$stackTrace\n```';
|
||||
}
|
||||
@@ -56,8 +75,49 @@ class CrashScreen extends StatelessWidget {
|
||||
style: Theme.of(ctx).textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
FutureBuilder<String>(
|
||||
future: _fetchVersion(),
|
||||
builder: (context, snapshot) => Text(
|
||||
'v${snapshot.data ?? '…'} • $_buildMode • '
|
||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
if (gitHash.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
FutureBuilder<PackageInfo>(
|
||||
future: PackageInfo.fromPlatform(),
|
||||
builder: (_, snapshot) {
|
||||
if (!snapshot.hasData) return const SizedBox.shrink();
|
||||
final version =
|
||||
'${snapshot.data!.version}+${snapshot.data!.buildNumber}';
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
final url = Uri.parse(
|
||||
'https://codeberg.org/guettli/sharedinbox/commit/$gitHash',
|
||||
);
|
||||
await launchUrl(
|
||||
url,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
'App Version: $version',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
final url = Uri.parse(
|
||||
|
||||
@@ -38,6 +38,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
var _sieveSsl = true;
|
||||
var _verbose = false;
|
||||
final _jmapUrlCtrl = TextEditingController();
|
||||
bool _hasStoredPassword = false;
|
||||
|
||||
// -- "Try connection" state ------------------------------------------------
|
||||
bool _tryTesting = false;
|
||||
@@ -50,6 +51,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
_smtpHostCtrl.addListener(_rebuild);
|
||||
_sieveHostCtrl.addListener(_rebuild);
|
||||
_imapHostCtrl.addListener(_rebuild);
|
||||
_passwordCtrl.addListener(_rebuild);
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
@@ -63,6 +65,11 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
context.pop();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await repo.getPassword(account.id);
|
||||
_hasStoredPassword = true;
|
||||
} catch (_) {}
|
||||
if (!mounted) return;
|
||||
_account = account;
|
||||
_displayNameCtrl.text = account.displayName;
|
||||
_usernameCtrl.text = account.username;
|
||||
@@ -84,6 +91,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
_smtpHostCtrl.removeListener(_rebuild);
|
||||
_sieveHostCtrl.removeListener(_rebuild);
|
||||
_imapHostCtrl.removeListener(_rebuild);
|
||||
_passwordCtrl.removeListener(_rebuild);
|
||||
for (final c in [
|
||||
_displayNameCtrl,
|
||||
_usernameCtrl,
|
||||
@@ -267,10 +275,12 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
),
|
||||
_field(
|
||||
_passwordCtrl,
|
||||
'New password (leave blank to keep)',
|
||||
_hasStoredPassword
|
||||
? 'New password (leave blank to keep)'
|
||||
: 'Password',
|
||||
key: const Key('editPasswordField'),
|
||||
obscure: true,
|
||||
required: false,
|
||||
required: !_hasStoredPassword,
|
||||
),
|
||||
if (account.type == AccountType.jmap) ...[
|
||||
const Divider(height: 32),
|
||||
@@ -345,10 +355,17 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
testing: _tryTesting,
|
||||
okMessage: _tryOk,
|
||||
errorMessage: _tryErr,
|
||||
onPressed: _tryConnection,
|
||||
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
|
||||
? _tryConnection
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton(onPressed: _save, child: const Text('Save')),
|
||||
FilledButton(
|
||||
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
|
||||
? _save
|
||||
: null,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -70,16 +70,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () {
|
||||
unawaited(_reply(context, header, body, replyAll: false));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.reply_all),
|
||||
tooltip: 'Reply all',
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () {
|
||||
unawaited(_reply(context, header, body, replyAll: true));
|
||||
unawaited(
|
||||
_replyWithRecipientDialog(context, header, body),
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
@@ -121,6 +114,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
tooltip: 'Snooze',
|
||||
onPressed: header == null ? null : () => _snooze(context, header),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.report_outlined),
|
||||
tooltip: 'Mark as spam',
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () {
|
||||
unawaited(_markAsSpam(context, header));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
tooltip: 'Delete',
|
||||
@@ -303,17 +305,78 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
return '\n\n— On $date, $from wrote:\n$quoted';
|
||||
}
|
||||
|
||||
Future<void> _reply(
|
||||
Future<void> _replyWithRecipientDialog(
|
||||
BuildContext context,
|
||||
Email header,
|
||||
EmailBody? body,
|
||||
) async {
|
||||
final account =
|
||||
await ref.read(accountRepositoryProvider).getAccount(header.accountId);
|
||||
final ownEmail = account?.email.toLowerCase() ?? '';
|
||||
|
||||
final seen = <String>{};
|
||||
final candidates = <_Candidate>[];
|
||||
|
||||
void addIfNew(EmailAddress addr, _Placement defaultPlacement) {
|
||||
final key = addr.email.toLowerCase();
|
||||
if (key == ownEmail || seen.contains(key)) return;
|
||||
seen.add(key);
|
||||
candidates.add(_Candidate(addr, defaultPlacement));
|
||||
}
|
||||
|
||||
for (final addr in header.from) {
|
||||
addIfNew(addr, _Placement.to);
|
||||
}
|
||||
for (final addr in header.to) {
|
||||
addIfNew(addr, _Placement.to);
|
||||
}
|
||||
for (final addr in header.cc) {
|
||||
addIfNew(addr, _Placement.cc);
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (candidates.length <= 1) {
|
||||
final to = candidates
|
||||
.where((c) => c.placement == _Placement.to)
|
||||
.map((c) => c.address.email)
|
||||
.join(', ');
|
||||
final cc = candidates
|
||||
.where((c) => c.placement == _Placement.cc)
|
||||
.map((c) => c.address.email)
|
||||
.join(', ');
|
||||
await _composeReply(context, header, body, to: to, cc: cc);
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<List<_Candidate>>(
|
||||
context: context,
|
||||
builder: (ctx) => _ReplyAllDialog(candidates: candidates),
|
||||
);
|
||||
|
||||
if (confirmed == null || !context.mounted) return;
|
||||
|
||||
final to = confirmed
|
||||
.where((c) => c.placement == _Placement.to)
|
||||
.map((c) => c.address.email)
|
||||
.join(', ');
|
||||
final cc = confirmed
|
||||
.where((c) => c.placement == _Placement.cc)
|
||||
.map((c) => c.address.email)
|
||||
.join(', ');
|
||||
await _composeReply(context, header, body, to: to, cc: cc);
|
||||
}
|
||||
|
||||
Future<void> _composeReply(
|
||||
BuildContext context,
|
||||
Email header,
|
||||
EmailBody? body, {
|
||||
required bool replyAll,
|
||||
required String to,
|
||||
required String cc,
|
||||
}) async {
|
||||
final to = header.from.isNotEmpty ? header.from.first.email : '';
|
||||
final subject = (header.subject?.startsWith('Re:') ?? false)
|
||||
? header.subject!
|
||||
: 'Re: ${header.subject ?? ''}';
|
||||
final cc = replyAll ? header.to.map((a) => a.email).join(', ') : '';
|
||||
final quoted = await _quotedBody(header, body);
|
||||
if (!context.mounted) return;
|
||||
unawaited(
|
||||
@@ -330,6 +393,38 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _markAsSpam(BuildContext context, Email header) async {
|
||||
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
||||
final junk = await mailboxRepo.findMailboxByRole(header.accountId, 'junk');
|
||||
|
||||
if (junk == null) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No Junk folder found')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(emailRepositoryProvider)
|
||||
.moveEmail(widget.emailId, junk.path);
|
||||
|
||||
unawaited(
|
||||
ref.read(undoServiceProvider.notifier).pushAction(
|
||||
UndoAction(
|
||||
id: DateTime.now().toIso8601String(),
|
||||
accountId: header.accountId,
|
||||
type: UndoType.move,
|
||||
emailIds: [widget.emailId],
|
||||
sourceMailboxPath: header.mailboxPath,
|
||||
destinationMailboxPath: junk.path,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (context.mounted) context.pop();
|
||||
}
|
||||
|
||||
Future<void> _forward(
|
||||
BuildContext context,
|
||||
Email header,
|
||||
@@ -670,6 +765,94 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
enum _Placement { to, cc, skip }
|
||||
|
||||
class _Candidate {
|
||||
_Candidate(this.address, this.placement);
|
||||
final EmailAddress address;
|
||||
_Placement placement;
|
||||
}
|
||||
|
||||
class _ReplyAllDialog extends StatefulWidget {
|
||||
const _ReplyAllDialog({required this.candidates});
|
||||
final List<_Candidate> candidates;
|
||||
|
||||
@override
|
||||
State<_ReplyAllDialog> createState() => _ReplyAllDialogState();
|
||||
}
|
||||
|
||||
class _ReplyAllDialogState extends State<_ReplyAllDialog> {
|
||||
late final List<_Candidate> _candidates;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_candidates = [
|
||||
for (final c in widget.candidates) _Candidate(c.address, c.placement),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Reply All'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
for (final c in _candidates)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
c.address.toString(),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SegmentedButton<_Placement>(
|
||||
showSelectedIcon: false,
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: _Placement.to,
|
||||
label: Text('To'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _Placement.cc,
|
||||
label: Text('Cc'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _Placement.skip,
|
||||
label: Text('Skip'),
|
||||
),
|
||||
],
|
||||
selected: {c.placement},
|
||||
onSelectionChanged: (s) =>
|
||||
setState(() => c.placement = s.first),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, _candidates),
|
||||
child: const Text('Reply'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MimeRow {
|
||||
const _MimeRow(this.depth, this.label);
|
||||
final int depth;
|
||||
|
||||
@@ -25,79 +25,55 @@ String _fmtBytes(int bytes) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
|
||||
/// Generates a markdown string for a single sync log entry.
|
||||
String buildSyncLogEntryMarkdown(SyncLogEntry entry) {
|
||||
final timeFmt = DateFormat('MMM d, HH:mm:ss');
|
||||
final proto =
|
||||
entry.protocol.isEmpty ? '' : ' (${entry.protocol.toUpperCase()})';
|
||||
final title = entry.isOk
|
||||
? 'Sync OK — ${timeFmt.format(entry.startedAt)}$proto'
|
||||
: 'Sync Error — ${timeFmt.format(entry.startedAt)}$proto';
|
||||
|
||||
final resultLabel = entry.isOk
|
||||
? 'OK'
|
||||
: entry.isPermanent == true
|
||||
? 'Error (permanent — sync stopped)'
|
||||
: 'Error (will retry)';
|
||||
|
||||
String _buildSyncEntryMarkdown(SyncLogEntry entry) {
|
||||
final buf = StringBuffer();
|
||||
buf.writeln('## $title');
|
||||
buf.writeln('## Sync Entry');
|
||||
buf.writeln();
|
||||
buf.writeln('| Property | Value |');
|
||||
buf.writeln('|----------|-------|');
|
||||
buf.writeln('| Result | $resultLabel |');
|
||||
buf.writeln('| Started | ${_timeFmt.format(entry.startedAt)} |');
|
||||
buf.writeln('| Finished | ${_timeFmt.format(entry.finishedAt)} |');
|
||||
buf.writeln('| Duration | ${_fmtDuration(entry.duration)} |');
|
||||
if (entry.protocol.isNotEmpty) {
|
||||
buf.writeln('| Protocol | ${entry.protocol.toUpperCase()} |');
|
||||
}
|
||||
buf.writeln('| Started | ${timeFmt.format(entry.startedAt)} |');
|
||||
buf.writeln('| Finished | ${timeFmt.format(entry.finishedAt)} |');
|
||||
buf.writeln('| Duration | ${_fmtDuration(entry.duration)} |');
|
||||
final statusLabel = entry.isOk
|
||||
? 'OK'
|
||||
: entry.isPermanent
|
||||
? 'Error (permanent)'
|
||||
: 'Error';
|
||||
buf.writeln('| Status | $statusLabel |');
|
||||
buf.writeln('| Emails fetched | ${entry.emailsFetched} |');
|
||||
buf.writeln('| Emails up-to-date | ${entry.emailsSkipped} |');
|
||||
buf.writeln('| Mailboxes synced | ${entry.mailboxesSynced} |');
|
||||
buf.writeln('| Pending changes flushed | ${entry.pendingFlushed} |');
|
||||
buf.writeln('| Data transferred | ${_fmtBytes(entry.bytesTransferred)} |');
|
||||
|
||||
if (entry.mailboxStats.isNotEmpty) {
|
||||
buf.writeln();
|
||||
buf.writeln('### Per Mailbox');
|
||||
buf.writeln('### Per mailbox');
|
||||
buf.writeln();
|
||||
buf.writeln('| Mailbox | Fetched | Up-to-date | Duration |');
|
||||
buf.writeln('|---------|---------|------------|----------|');
|
||||
for (final m in entry.mailboxStats) {
|
||||
final dur = m.duration != null ? _fmtDuration(m.duration!) : '—';
|
||||
final dur = m.duration != null ? _fmtDuration(m.duration!) : '-';
|
||||
buf.writeln('| ${m.mailboxPath} | ${m.fetched} | ${m.skipped} | $dur |');
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.errorMessage != null) {
|
||||
buf.writeln();
|
||||
buf.writeln('### Error Message');
|
||||
buf.writeln('**Error:**');
|
||||
buf.writeln();
|
||||
buf.writeln('```');
|
||||
buf.writeln(entry.errorMessage);
|
||||
buf.writeln('```');
|
||||
}
|
||||
|
||||
if (entry.stackTrace != null) {
|
||||
buf.writeln();
|
||||
buf.writeln('### Stack Trace');
|
||||
buf.writeln('**Stack trace:**');
|
||||
buf.writeln();
|
||||
buf.writeln('```');
|
||||
buf.writeln(entry.stackTrace);
|
||||
buf.write(entry.stackTrace);
|
||||
buf.writeln('```');
|
||||
}
|
||||
|
||||
if (entry.protocolLog != null) {
|
||||
buf.writeln();
|
||||
buf.writeln('### Protocol Log');
|
||||
buf.writeln();
|
||||
buf.writeln('```');
|
||||
buf.writeln(entry.protocolLog);
|
||||
buf.writeln('```');
|
||||
}
|
||||
|
||||
return buf.toString().trimRight();
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
class SyncLogScreen extends ConsumerStatefulWidget {
|
||||
@@ -148,32 +124,32 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
|
||||
ref.read(syncManagerProvider).syncNow(widget.accountId);
|
||||
}
|
||||
|
||||
Future<void> _copyEntry(SyncLogEntry entry) async {
|
||||
PackageInfo? pkg;
|
||||
try {
|
||||
pkg = await PackageInfo.fromPlatform();
|
||||
} catch (_) {}
|
||||
final deviceInfo = await fetchAndroidDeviceInfo();
|
||||
if (!mounted) return;
|
||||
|
||||
Future<void> _copyEntry(SyncLogEntry entry, BuildContext context) async {
|
||||
final accounts =
|
||||
await ref.read(accountRepositoryProvider).observeAccounts().first;
|
||||
final imapCount = accounts.where((a) => a.type == AccountType.imap).length;
|
||||
final jmapCount = accounts.where((a) => a.type == AccountType.jmap).length;
|
||||
|
||||
if (!mounted) return;
|
||||
final entryMd = buildSyncLogEntryMarkdown(entry);
|
||||
PackageInfo? pkg;
|
||||
try {
|
||||
pkg = await PackageInfo.fromPlatform();
|
||||
} catch (_) {}
|
||||
|
||||
final deviceModel = await getDeviceModel();
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
final syncMd = _buildSyncEntryMarkdown(entry);
|
||||
final aboutMd = buildAboutMarkdown(
|
||||
context,
|
||||
pkg,
|
||||
imapCount,
|
||||
jmapCount,
|
||||
deviceInfo,
|
||||
context: context,
|
||||
pkg: pkg,
|
||||
imapCount: imapCount,
|
||||
jmapCount: jmapCount,
|
||||
deviceModel: deviceModel,
|
||||
);
|
||||
await Clipboard.setData(
|
||||
ClipboardData(text: '$entryMd\n\n---\n\n$aboutMd'),
|
||||
);
|
||||
if (mounted) {
|
||||
await Clipboard.setData(ClipboardData(text: '$syncMd\n$aboutMd'));
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 3),
|
||||
@@ -212,7 +188,7 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
|
||||
itemCount: _entries.length,
|
||||
itemBuilder: (ctx, i) => _SyncLogTile(
|
||||
entry: _entries[i],
|
||||
onCopy: () => unawaited(_copyEntry(_entries[i])),
|
||||
onCopy: () => _copyEntry(_entries[i], ctx),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -233,6 +209,12 @@ class _SyncLogTile extends StatelessWidget {
|
||||
final theme = Theme.of(context);
|
||||
final errorColor = theme.colorScheme.error;
|
||||
|
||||
final subtitleText = entry.isOk
|
||||
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
|
||||
: entry.isPermanent
|
||||
? 'Error (permanent) · took $durationLabel'
|
||||
: 'Error · took $durationLabel';
|
||||
|
||||
return ExpansionTile(
|
||||
leading: Icon(
|
||||
entry.isOk ? Icons.check_circle : Icons.error_outline,
|
||||
@@ -243,9 +225,7 @@ class _SyncLogTile extends StatelessWidget {
|
||||
style: entry.isOk ? null : TextStyle(color: errorColor),
|
||||
),
|
||||
subtitle: Text(
|
||||
entry.isOk
|
||||
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
|
||||
: 'Error${entry.isPermanent == true ? ' (permanent)' : ''} · took $durationLabel',
|
||||
subtitleText,
|
||||
style: TextStyle(fontSize: 12, color: entry.isOk ? null : errorColor),
|
||||
),
|
||||
trailing: Row(
|
||||
@@ -253,7 +233,7 @@ class _SyncLogTile extends StatelessWidget {
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
tooltip: 'Copy to clipboard',
|
||||
tooltip: 'Copy as markdown',
|
||||
onPressed: onCopy,
|
||||
),
|
||||
const Icon(Icons.expand_more),
|
||||
@@ -300,18 +280,31 @@ class _SyncLogTile extends StatelessWidget {
|
||||
style: TextStyle(color: errorColor, fontSize: 12),
|
||||
),
|
||||
),
|
||||
if (entry.stackTrace != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
if (entry.stackTrace != null) ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 6, bottom: 2),
|
||||
child: Text(
|
||||
'Stack trace',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
entry.stackTrace!,
|
||||
style: TextStyle(
|
||||
color: errorColor,
|
||||
fontSize: 10,
|
||||
fontFamily: 'monospace',
|
||||
color: Colors.red[300],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (entry.protocolLog != null) ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 6, bottom: 2),
|
||||
|
||||
@@ -3,39 +3,18 @@ import 'dart:io';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:sharedinbox/core/db_schema_version.dart';
|
||||
|
||||
const _gitHash = String.fromEnvironment('GIT_HASH');
|
||||
|
||||
/// Returns Android device info (manufacturer, model, OS release).
|
||||
/// Returns an empty map on non-Android platforms or if the plugin fails.
|
||||
Future<Map<String, String>> fetchAndroidDeviceInfo() async {
|
||||
if (!Platform.isAndroid) return {};
|
||||
try {
|
||||
final info = await DeviceInfoPlugin().androidInfo;
|
||||
return {
|
||||
'Manufacturer': info.manufacturer,
|
||||
'Model': info.model,
|
||||
'Android Version': info.version.release,
|
||||
};
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
String _capitalize(String s) =>
|
||||
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
|
||||
|
||||
/// Builds the standard "about" markdown table for sharing / bug reports.
|
||||
///
|
||||
/// Pass [deviceInfo] from [fetchAndroidDeviceInfo].
|
||||
/// Pass [imapCount] and [jmapCount] when available; both default to 0.
|
||||
String buildAboutMarkdown(
|
||||
BuildContext context,
|
||||
/// Builds the About markdown table used in [AboutScreen] and sync log copies.
|
||||
String buildAboutMarkdown({
|
||||
required BuildContext context,
|
||||
PackageInfo? pkg,
|
||||
int imapCount,
|
||||
int jmapCount,
|
||||
Map<String, String> deviceInfo,
|
||||
) {
|
||||
required int imapCount,
|
||||
required int jmapCount,
|
||||
String? deviceModel,
|
||||
}) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
final physW = (size.width * pixelRatio).toInt();
|
||||
@@ -46,13 +25,15 @@ String buildAboutMarkdown(
|
||||
: version;
|
||||
final osName = _capitalize(Platform.operatingSystem);
|
||||
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
final textScale =
|
||||
MediaQuery.of(context).textScaler.scale(1.0).toStringAsFixed(1);
|
||||
|
||||
final gitCommitLine = _gitHash.isNotEmpty
|
||||
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
|
||||
: '';
|
||||
|
||||
final deviceLines =
|
||||
deviceInfo.entries.map((e) => '| ${e.key} | ${e.value} |\n').join();
|
||||
final deviceModelLine =
|
||||
deviceModel != null ? '| Device Model | $deviceModel |\n' : '';
|
||||
|
||||
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
|
||||
'| Property | Value |\n'
|
||||
@@ -61,13 +42,34 @@ String buildAboutMarkdown(
|
||||
'$gitCommitLine'
|
||||
'| Platform | ${Platform.operatingSystem} |\n'
|
||||
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
|
||||
'$deviceLines'
|
||||
'$deviceModelLine'
|
||||
'| Resolution | ${physW}x$physH px'
|
||||
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
|
||||
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
|
||||
'| Dart Version | ${Platform.version.split(' ').first} |\n'
|
||||
'| Processors | ${Platform.numberOfProcessors} |\n'
|
||||
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
|
||||
'| Locale | $locale |\n'
|
||||
'| Text Scale | $textScale× |\n'
|
||||
'| DB Schema Version | $dbSchemaVersion |\n'
|
||||
'| IMAP Accounts | $imapCount |\n'
|
||||
'| JMAP Accounts | $jmapCount |\n';
|
||||
}
|
||||
|
||||
/// Fetches device model string, or null when unavailable.
|
||||
Future<String?> getDeviceModel() async {
|
||||
try {
|
||||
final info = DeviceInfoPlugin();
|
||||
if (Platform.isAndroid) {
|
||||
final android = await info.androidInfo;
|
||||
return '${android.manufacturer} / ${android.model}';
|
||||
} else if (Platform.isIOS) {
|
||||
final ios = await info.iosInfo;
|
||||
return ios.utsname.machine;
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _capitalize(String s) =>
|
||||
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
|
||||
|
||||
+3
-3
@@ -1133,13 +1133,13 @@ packages:
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
dependency: "direct overridden"
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
|
||||
sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.30"
|
||||
version: "6.3.24"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -90,3 +90,7 @@ dependency_overrides:
|
||||
# (SIGSEGV in libdartjni.so FindClassUnchecked). Pin to 2.2.20 which uses
|
||||
# stable Pigeon and is known to work reliably.
|
||||
path_provider_android: ">=2.2.0 <2.2.21"
|
||||
# url_launcher_android 6.3.25 updated to Pigeon 26, which causes a
|
||||
# channel-error on launchUrl on some Android devices (same root cause as
|
||||
# path_provider_android). Pin to <6.3.25 which uses stable Pigeon.
|
||||
url_launcher_android: ">=6.3.0 <6.3.25"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
],
|
||||
"labels": ["dependencies"],
|
||||
"github-actions": {
|
||||
"fileMatch": ["^\\.forgejo/workflows/[^/]+\\.ya?ml$"]
|
||||
}
|
||||
}
|
||||
+215
-19
@@ -8,21 +8,25 @@ Flow
|
||||
a. Age > 1 h → kill it, set its issue to State/Question, exit 1
|
||||
b. Age ≤ 1 h → print status, exit 0 (let it keep working)
|
||||
2. No agent running → extract pending_issue from state (if any), then check CI
|
||||
a. pending_issue + open PR → check PR branch CI, merge/fix/wait as needed
|
||||
b. Catch-up: orphaned issue-N-fix PRs with passing CI → merge them
|
||||
c. Main CI running → save pending-ci state, exit 0
|
||||
d. Main CI failed → start fix-CI agent (pushes fix to main), exit 0
|
||||
e. Main CI ok + pending_issue → close the issue, exit 0 (dead code path —
|
||||
section 2a always returns first)
|
||||
f. Main CI ok (or no run yet) → find oldest Ready issue, start issue agent,
|
||||
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
|
||||
c. Catch-up: orphaned issue-N-fix PRs with passing CI → merge them
|
||||
d. Main CI running → save pending-ci state, exit 0
|
||||
e. Main CI failed → start fix-CI agent (pushes fix to main), exit 0
|
||||
f. Main CI ok + pending_issue → close the issue, exit 0 (dead code path —
|
||||
section 2b always returns first)
|
||||
g. Main CI ok (or no run yet) → find oldest ToPlan issue, start plan agent,
|
||||
save state, exit 0
|
||||
g. No Ready issues → print "nothing to do", exit 0
|
||||
h. No ToPlan issues → find oldest Ready issue, start issue agent,
|
||||
save state, exit 0
|
||||
i. No Ready issues → print "nothing to do", exit 0
|
||||
|
||||
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.
|
||||
|
||||
State file: ~/.sharedinbox-agent-state.json
|
||||
{ "pid": 12345, "issue": 91,
|
||||
"started_at": "2026-05-15T12:00:00+00:00", "type": "issue" }
|
||||
"started_at": "2026-05-15T12:00:00+00:00", "type": "issue|plan|ci-fix|pending-ci" }
|
||||
|
||||
Output is written to ~/.sharedinbox-agent-logs/<session>-<timestamp>.log.
|
||||
To resume the Claude conversation, look up the session UUID first:
|
||||
@@ -38,6 +42,7 @@ import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -53,7 +58,9 @@ os.environ["PATH"] = (
|
||||
REPO = "guettli/sharedinbox"
|
||||
REPO_URL = f"https://codeberg.org/{REPO}"
|
||||
STATE_FILE = Path.home() / ".sharedinbox-agent-state.json"
|
||||
HEARTBEAT_FILE = Path.home() / ".sharedinbox-agent-heartbeat"
|
||||
MAX_AGENT_AGE_SECONDS = 3600 # 1 hour
|
||||
MAX_HEARTBEAT_AGE_SECONDS = 7200 # 2 hours
|
||||
CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects" / (
|
||||
"-" + str(Path.home())[1:].replace("/", "-")
|
||||
)
|
||||
@@ -63,6 +70,8 @@ LABEL_READY = "State/Ready"
|
||||
LABEL_IN_PROGRESS = "State/InProgress"
|
||||
LABEL_QUESTION = "State/Question"
|
||||
LABEL_PRIO_HIGH = "Prio/High"
|
||||
LABEL_TO_PLAN = "State/ToPlan"
|
||||
LABEL_PLANNED = "State/Planned"
|
||||
|
||||
# Only pick up issues filed by these accounts.
|
||||
ALLOWED_ISSUE_AUTHORS = {"guettli", "guettlibot", "guettlibot2"}
|
||||
@@ -145,17 +154,39 @@ def _ready_issues() -> list[dict]:
|
||||
return ready
|
||||
|
||||
|
||||
def _latest_main_ci_run() -> dict | None:
|
||||
"""Return the latest CI run on the main branch (excludes PR runs).
|
||||
def _to_plan_issues() -> list[dict]:
|
||||
"""Return open issues with State/ToPlan, Prio/High first, then oldest."""
|
||||
result = subprocess.run(
|
||||
["fgj", "--hostname", "codeberg.org", "issue", "list",
|
||||
"--repo", REPO, "--state", "open", "--json"],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
data = json.loads(result.stdout) if result.stdout.strip() else []
|
||||
to_plan = [
|
||||
i for i in data
|
||||
if any(lbl["name"] == LABEL_TO_PLAN for lbl in i.get("labels", []))
|
||||
and i.get("user", {}).get("login", "") in ALLOWED_ISSUE_AUTHORS
|
||||
]
|
||||
to_plan.sort(key=lambda i: (
|
||||
0 if any(lbl["name"] == LABEL_PRIO_HIGH for lbl in i.get("labels", [])) else 1,
|
||||
i["number"],
|
||||
))
|
||||
return to_plan
|
||||
|
||||
Using the global latest run (limit=1) is wrong: a passing or failing run
|
||||
on a PR branch could mask the true state of main. We filter to non-PR
|
||||
events on the 'main' prettyref so section-3 logic only reacts to main.
|
||||
|
||||
def _latest_main_ci_run() -> dict | None:
|
||||
"""Return the latest ci.yml run on the main branch.
|
||||
|
||||
Forgejo reports scheduled/dispatch workflows (e.g. deploy.yml) with
|
||||
event=push and prettyref=main, so filtering by event alone is not enough.
|
||||
We also require workflow_id == "ci.yml".
|
||||
"""
|
||||
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
|
||||
runs = (data or {}).get("workflow_runs", [])
|
||||
for run in runs:
|
||||
if run.get("event") != "pull_request" and run.get("prettyref") == "main":
|
||||
if (run.get("event") == "push"
|
||||
and run.get("prettyref") == "main"
|
||||
and run.get("workflow_id") == "ci.yml"):
|
||||
return run
|
||||
return None
|
||||
|
||||
@@ -177,7 +208,7 @@ def _latest_ci_run_for_branch(branch: str) -> dict | None:
|
||||
return run
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
else:
|
||||
elif run.get("event") == "push":
|
||||
if run.get("prettyref") == branch:
|
||||
return run
|
||||
return None
|
||||
@@ -235,11 +266,54 @@ def _latest_ci_run_for_pr(pr_number: int) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def _get_issue_labels(issue: int) -> list[str]:
|
||||
"""Return label names for an issue."""
|
||||
data = _tea_get(f"repos/{REPO}/issues/{issue}")
|
||||
if not data:
|
||||
return []
|
||||
return [lbl["name"] for lbl in data.get("labels", [])]
|
||||
|
||||
|
||||
def _merge_pr(pr_number: int) -> None:
|
||||
"""Squash-merge a PR via fgj."""
|
||||
_fgj("pr", "merge", str(pr_number), "--repo", REPO, "--merge-method", "squash")
|
||||
|
||||
|
||||
def _handle_pr_still_open_after_merge(pr_number: int, branch: str, issue_num: int | None) -> str:
|
||||
"""Handle a PR that is still open after a successful _merge_pr() call.
|
||||
|
||||
Returns one of:
|
||||
"rebase-spawned" — merge conflict detected; rebase agent started, state written
|
||||
"merged" — PR closed after a retry
|
||||
"fallback" — all options exhausted; caller should set State/Question
|
||||
"""
|
||||
pr_data = _tea_get(f"repos/{REPO}/pulls/{pr_number}")
|
||||
mergeable = (pr_data or {}).get("mergeable")
|
||||
|
||||
if mergeable is False:
|
||||
prompt = (
|
||||
f"Rebase branch `{branch}` onto main to resolve merge conflicts, then push. "
|
||||
"Do not change any logic — only resolve conflicts and push."
|
||||
)
|
||||
session_name = f"rebase-pr-{pr_number}"
|
||||
pid = _start_agent(prompt, session_name)
|
||||
_write_state(pid, issue_num, "pending-ci", session_name=session_name)
|
||||
print(f"PR #{pr_number} has merge conflicts — spawned rebase agent (pid={pid}).")
|
||||
return "rebase-spawned"
|
||||
|
||||
for attempt in range(1, 3):
|
||||
time.sleep(5)
|
||||
try:
|
||||
_merge_pr(pr_number)
|
||||
except RuntimeError as e:
|
||||
print(f"PR #{pr_number} merge retry {attempt} failed: {e}")
|
||||
if not _find_pr_for_branch(branch):
|
||||
print(f"PR #{pr_number} merged on retry {attempt}.")
|
||||
return "merged"
|
||||
|
||||
return "fallback"
|
||||
|
||||
|
||||
# ── state file ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -273,6 +347,12 @@ def _clear_state() -> None:
|
||||
STATE_FILE.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _update_heartbeat() -> None:
|
||||
"""Record that the agent loop ran right now."""
|
||||
HEARTBEAT_FILE.write_text(datetime.now(timezone.utc).isoformat())
|
||||
HEARTBEAT_FILE.chmod(0o600)
|
||||
|
||||
|
||||
def _find_session_uuid(session_name: str) -> str | None:
|
||||
"""Return the Claude session UUID for *session_name*, or None if not found.
|
||||
|
||||
@@ -345,6 +425,15 @@ def _agent_alive(state: dict) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _is_claude_process(pid: int) -> bool:
|
||||
"""Return True if pid's comm name indicates it is a claude/node process."""
|
||||
try:
|
||||
comm = Path(f"/proc/{pid}/comm").read_text().strip()
|
||||
return comm in ("claude", "node")
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _agent_age_seconds(state: dict) -> float:
|
||||
"""Seconds elapsed since the agent was launched, from the state file timestamp."""
|
||||
try:
|
||||
@@ -379,11 +468,13 @@ def _git_summary() -> str:
|
||||
def _kill_agent(state: dict) -> None:
|
||||
"""Forcefully stop the running agent."""
|
||||
pid = state.get("pid")
|
||||
if pid:
|
||||
if pid and _is_claude_process(pid):
|
||||
try:
|
||||
os.kill(pid, 9)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
elif pid:
|
||||
print(f"WARNING: pid {pid} is not a claude process — skipping kill to avoid hitting recycled PID")
|
||||
|
||||
|
||||
# ── subcommands ───────────────────────────────────────────────────────────────
|
||||
@@ -431,12 +522,44 @@ def cmd_list() -> int:
|
||||
return 0
|
||||
|
||||
|
||||
# ── monitor subcommand ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def cmd_monitor() -> int:
|
||||
"""Check that the agent loop has run within the last 2 hours.
|
||||
|
||||
Exits 0 if healthy, 1 if the heartbeat is missing or stale.
|
||||
Intended to be called from a scheduled CI job or cron every 2 hours.
|
||||
"""
|
||||
if not HEARTBEAT_FILE.exists():
|
||||
print(
|
||||
f"WARNING: Agent loop heartbeat file missing — "
|
||||
f"the loop may not have run yet or the file was deleted ({HEARTBEAT_FILE})."
|
||||
)
|
||||
return 1
|
||||
try:
|
||||
last_run = datetime.fromisoformat(HEARTBEAT_FILE.read_text().strip())
|
||||
except ValueError:
|
||||
print(f"WARNING: Agent loop heartbeat file is corrupted: {HEARTBEAT_FILE}")
|
||||
return 1
|
||||
age = (datetime.now(timezone.utc) - last_run).total_seconds()
|
||||
if age > MAX_HEARTBEAT_AGE_SECONDS:
|
||||
print(
|
||||
f"WARNING: Agent loop last ran {age / 3600:.1f}h ago "
|
||||
f"(limit: {MAX_HEARTBEAT_AGE_SECONDS // 3600}h) — the loop may be stalled."
|
||||
)
|
||||
return 1
|
||||
print(f"Agent loop is healthy. Last run: {age / 60:.0f} min ago.")
|
||||
return 0
|
||||
|
||||
|
||||
# ── main flow ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _run_loop() -> int:
|
||||
now = datetime.now(timezone.utc)
|
||||
print(f"---------------------- Starting {now.strftime('%Y-%m-%d %H:%MZ')}")
|
||||
_update_heartbeat()
|
||||
|
||||
state = _read_state()
|
||||
|
||||
@@ -491,13 +614,29 @@ def _run_loop() -> int:
|
||||
|
||||
# Agent not running (or no state) — extract any pending issue, then clean up.
|
||||
pending_issue: int | None = None
|
||||
pending_type: str | None = None
|
||||
ci_run_id_at_start: int | None = None
|
||||
if state:
|
||||
pending_issue = state.get("issue")
|
||||
pending_type = state.get("type")
|
||||
ci_run_id_at_start = state.get("ci_run_id_at_start")
|
||||
_clear_state()
|
||||
|
||||
# ── 2. Check for a PR opened by the agent ────────────────────────────────
|
||||
# ── 2a. Finished planning agent ───────────────────────────────────────────
|
||||
if pending_issue and pending_type == "plan":
|
||||
session_name = f"plan-issue-{pending_issue}"
|
||||
uuid = _find_session_uuid(session_name)
|
||||
if uuid:
|
||||
resume_cmd = f"claude --resume {shlex.quote(uuid)}"
|
||||
_comment_issue(
|
||||
pending_issue,
|
||||
f"Planning complete. To resume this session:\n\n```\n{resume_cmd}\n```",
|
||||
)
|
||||
_set_labels(pending_issue, add=[LABEL_PLANNED], remove=[LABEL_IN_PROGRESS])
|
||||
print(f"Planning done for {_issue_url(pending_issue)} — set State/Planned.")
|
||||
return 0
|
||||
|
||||
# ── 2b. Check for a PR opened by the agent ───────────────────────────────
|
||||
if pending_issue:
|
||||
branch = f"issue-{pending_issue}-fix"
|
||||
pr = _find_pr_for_branch(branch)
|
||||
@@ -573,6 +712,13 @@ def _run_loop() -> int:
|
||||
)
|
||||
return 0
|
||||
if _find_pr_for_branch(branch):
|
||||
merge_result = _handle_pr_still_open_after_merge(pr_number, branch, pending_issue)
|
||||
if merge_result == "rebase-spawned":
|
||||
return 0
|
||||
if merge_result == "merged":
|
||||
_close_issue(pending_issue)
|
||||
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
|
||||
return 0
|
||||
print(f"PR #{pr_number} is still open after merge attempt — setting to State/Question.")
|
||||
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||
_comment_issue(
|
||||
@@ -629,6 +775,9 @@ def _run_loop() -> int:
|
||||
continue
|
||||
|
||||
if pr_run and pr_run.get("status") == "success":
|
||||
if issue_num and LABEL_QUESTION in _get_issue_labels(issue_num):
|
||||
print(f"Catch-up: PR #{pr_number} — issue #{issue_num} is State/Question, skipping.")
|
||||
continue
|
||||
print(f"Catch-up: CI passed on PR #{pr_number} ({pr_url}) — merging.")
|
||||
try:
|
||||
_merge_pr(pr_number)
|
||||
@@ -638,6 +787,16 @@ def _run_loop() -> int:
|
||||
# Verify the merge actually happened; fgj can exit 0 without merging
|
||||
# (e.g. branch-protection rules not satisfied).
|
||||
if _find_pr_for_branch(branch):
|
||||
merge_result = _handle_pr_still_open_after_merge(pr_number, branch, issue_num)
|
||||
if merge_result == "rebase-spawned":
|
||||
return 0
|
||||
if merge_result == "merged":
|
||||
if issue_num:
|
||||
_close_issue(issue_num)
|
||||
print(f"Catch-up: merged PR #{pr_number} and closed issue #{issue_num} after retry.")
|
||||
else:
|
||||
print(f"Catch-up: merged PR #{pr_number} after retry.")
|
||||
return 0
|
||||
print(
|
||||
f"Catch-up: PR #{pr_number} is still open after merge attempt "
|
||||
"— skipping to avoid infinite retry."
|
||||
@@ -725,10 +884,44 @@ def _run_loop() -> int:
|
||||
print(f"CI passed{ci_run_part} — closed {_issue_url(pending_issue)}.")
|
||||
return 0
|
||||
|
||||
# Find a ToPlan issue — planning takes priority over implementation.
|
||||
to_plan = _to_plan_issues()
|
||||
if to_plan:
|
||||
issue = to_plan[0]
|
||||
issue_number = issue["number"]
|
||||
issue_title = issue["title"]
|
||||
issue_body = issue.get("body", "")
|
||||
|
||||
print(f"Starting planning agent for {_issue_url(issue_number)} {issue_title}")
|
||||
_set_labels(issue_number, add=[LABEL_IN_PROGRESS], remove=[LABEL_TO_PLAN])
|
||||
|
||||
plan_prompt = f"""Analyze Codeberg issue #{issue_number} in the guettli/sharedinbox repository and write a detailed implementation plan.
|
||||
|
||||
Issue title: {issue_title}
|
||||
|
||||
Issue body:
|
||||
{issue_body}
|
||||
|
||||
Instructions:
|
||||
- Read and understand the issue thoroughly.
|
||||
- Explore the relevant parts of the codebase to understand the current structure.
|
||||
- Write a detailed implementation plan as a comment on the issue using:
|
||||
fgj issue comment {issue_number} --repo {REPO} --body "..."
|
||||
The plan should cover: which files to change, what approach to take, and any risks or open questions.
|
||||
- Do NOT write any code, do NOT create any branches or PRs, do NOT modify any files.
|
||||
- If the issue is unclear or you need more information, set the label to State/Question
|
||||
and stop (do NOT close the issue).
|
||||
- When you have posted the plan as an issue comment, stop.
|
||||
"""
|
||||
session_name = f"plan-issue-{issue_number}"
|
||||
pid = _start_agent(plan_prompt, session_name)
|
||||
_write_state(pid, issue_number, "plan", issue_title, session_name=session_name)
|
||||
return 0
|
||||
|
||||
# Find a Ready issue.
|
||||
issues = _ready_issues()
|
||||
if not issues:
|
||||
print("No issues with State/Ready. Nothing to do.")
|
||||
print("No issues with State/ToPlan or State/Ready. Nothing to do.")
|
||||
return 0
|
||||
|
||||
issue = issues[0]
|
||||
@@ -784,10 +977,13 @@ def main() -> int:
|
||||
parser = argparse.ArgumentParser(prog="agent_loop")
|
||||
sub = parser.add_subparsers(dest="cmd")
|
||||
sub.add_parser("list", help="List recent agent sessions")
|
||||
sub.add_parser("monitor", help="Check that the loop ran within the last 2 hours")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.cmd == "list":
|
||||
return cmd_list()
|
||||
if args.cmd == "monitor":
|
||||
return cmd_monitor()
|
||||
return _run_loop()
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ const _minCoveragePercent = 80;
|
||||
|
||||
// Pure-abstract interfaces: no executable code, Dart VM never instruments them.
|
||||
const _noCode = {
|
||||
'lib/core/db_schema_version.dart',
|
||||
'lib/core/repositories/account_repository.dart',
|
||||
'lib/core/repositories/draft_repository.dart',
|
||||
'lib/core/repositories/email_repository.dart',
|
||||
|
||||
@@ -33,9 +33,6 @@ def list_remote_files(ssh_user: str, ssh_host: str, pattern: str) -> list[str]:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ssh",
|
||||
"-v",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-i", "/root/.ssh/id_ed25519",
|
||||
f"{ssh_user}@{ssh_host}",
|
||||
f"find {REMOTE_BUILDS_DIR} -name '{pattern}' -type f | sort",
|
||||
],
|
||||
|
||||
+284
-17
@@ -6,6 +6,7 @@ import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -88,21 +89,47 @@ class TestAgentAlive(unittest.TestCase):
|
||||
self.assertFalse(agent_loop._agent_alive({"pid": None}))
|
||||
|
||||
|
||||
class TestIsClaudeProcess(unittest.TestCase):
|
||||
def test_returns_true_for_claude_comm(self):
|
||||
with patch.object(agent_loop.Path, "read_text", return_value="claude\n"):
|
||||
self.assertTrue(agent_loop._is_claude_process(1234))
|
||||
|
||||
def test_returns_true_for_node_comm(self):
|
||||
with patch.object(agent_loop.Path, "read_text", return_value="node\n"):
|
||||
self.assertTrue(agent_loop._is_claude_process(1234))
|
||||
|
||||
def test_returns_false_for_other_process(self):
|
||||
with patch.object(agent_loop.Path, "read_text", return_value="bash\n"):
|
||||
self.assertFalse(agent_loop._is_claude_process(1234))
|
||||
|
||||
def test_returns_false_when_proc_missing(self):
|
||||
with patch.object(agent_loop.Path, "read_text", side_effect=OSError):
|
||||
self.assertFalse(agent_loop._is_claude_process(1234))
|
||||
|
||||
|
||||
class TestKillAgent(unittest.TestCase):
|
||||
def test_kill_sends_sigkill(self):
|
||||
with patch("agent_loop.os.kill") as mock_kill:
|
||||
agent_loop._kill_agent({"pid": 1234})
|
||||
mock_kill.assert_called_once_with(1234, 9)
|
||||
with patch("agent_loop._is_claude_process", return_value=True):
|
||||
with patch("agent_loop.os.kill") as mock_kill:
|
||||
agent_loop._kill_agent({"pid": 1234})
|
||||
mock_kill.assert_called_once_with(1234, 9)
|
||||
|
||||
def test_kill_ignores_missing_process(self):
|
||||
with patch("agent_loop.os.kill", side_effect=ProcessLookupError):
|
||||
agent_loop._kill_agent({"pid": 1234}) # Should not raise.
|
||||
with patch("agent_loop._is_claude_process", return_value=True):
|
||||
with patch("agent_loop.os.kill", side_effect=ProcessLookupError):
|
||||
agent_loop._kill_agent({"pid": 1234}) # Should not raise.
|
||||
|
||||
def test_kill_noop_when_no_pid(self):
|
||||
with patch("agent_loop.os.kill") as mock_kill:
|
||||
agent_loop._kill_agent({})
|
||||
mock_kill.assert_not_called()
|
||||
|
||||
def test_kill_skips_recycled_pid(self):
|
||||
with patch("agent_loop._is_claude_process", return_value=False):
|
||||
with patch("agent_loop.os.kill") as mock_kill:
|
||||
agent_loop._kill_agent({"pid": 1234})
|
||||
mock_kill.assert_not_called()
|
||||
|
||||
|
||||
class TestStartAgent(unittest.TestCase):
|
||||
def _make_mock_proc(self, pid=42):
|
||||
@@ -174,7 +201,8 @@ class TestMain(unittest.TestCase):
|
||||
return 55
|
||||
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[self._make_issue(10)]), \
|
||||
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
|
||||
patch("agent_loop._start_agent", side_effect=fake_start_agent), \
|
||||
@@ -200,7 +228,8 @@ class TestMain(unittest.TestCase):
|
||||
captured["remove"] = remove
|
||||
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[self._make_issue(7)]), \
|
||||
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
|
||||
patch("agent_loop._start_agent", return_value=99), \
|
||||
@@ -213,7 +242,8 @@ class TestMain(unittest.TestCase):
|
||||
def test_no_ready_issues_does_nothing(self):
|
||||
"""main() exits cleanly with 0 when there are no ready issues."""
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[]), \
|
||||
patch("agent_loop._set_labels") as mock_labels, \
|
||||
patch("agent_loop._start_agent") as mock_start:
|
||||
@@ -232,7 +262,8 @@ class TestMain(unittest.TestCase):
|
||||
return 77
|
||||
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[self._make_issue(42)]), \
|
||||
patch("agent_loop._set_labels"), \
|
||||
patch("agent_loop._start_agent", side_effect=fake_start_agent), \
|
||||
@@ -266,8 +297,9 @@ class TestPendingCi(unittest.TestCase):
|
||||
|
||||
def test_closes_issue_when_ci_passes_after_agent_finishes(self):
|
||||
"""After issue agent finishes, loop merges the PR and closes the issue once CI is green."""
|
||||
# First call: PR found open. Second call (post-merge verification): PR closed.
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._merge_pr") as mock_merge, \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
@@ -282,7 +314,7 @@ class TestPendingCi(unittest.TestCase):
|
||||
"""'CI passed' line includes the CI run URL when a run is available."""
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 4145144, "status": "success"}), \
|
||||
patch("agent_loop._merge_pr"), \
|
||||
patch("agent_loop._close_issue"), \
|
||||
@@ -392,7 +424,7 @@ class TestPendingCi(unittest.TestCase):
|
||||
def test_closes_issue_after_ci_fix_and_ci_passes(self):
|
||||
"""After ci-fix agent finishes and CI passes on PR branch, the pending issue is closed."""
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10, "ci-fix")), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._merge_pr") as mock_merge, \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
@@ -409,7 +441,8 @@ class TestPendingCi(unittest.TestCase):
|
||||
"pid": 999999999, "issue": None, "started_at": "2026-01-01T00:00:00+00:00",
|
||||
"type": "ci-fix",
|
||||
}), \
|
||||
patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._ready_issues", return_value=[]), \
|
||||
patch("agent_loop._clear_state"):
|
||||
@@ -425,7 +458,8 @@ class TestOutputFormat(unittest.TestCase):
|
||||
def test_output_starts_with_header(self):
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[]), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
@@ -436,7 +470,8 @@ class TestOutputFormat(unittest.TestCase):
|
||||
def test_no_agent_loop_prefix_in_output(self):
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[]), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
@@ -446,7 +481,8 @@ class TestOutputFormat(unittest.TestCase):
|
||||
run = {"id": 4145144, "status": "running"}
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._latest_ci_run", return_value=run), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=run), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144",
|
||||
@@ -456,7 +492,8 @@ class TestOutputFormat(unittest.TestCase):
|
||||
issue = {"number": 128, "title": "Fix something", "body": "", "labels": []}
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[issue]), \
|
||||
patch("agent_loop._set_labels"), \
|
||||
patch("agent_loop._start_agent", return_value=99), \
|
||||
@@ -468,6 +505,47 @@ class TestOutputFormat(unittest.TestCase):
|
||||
self.assertIn("Fix something", output)
|
||||
|
||||
|
||||
class TestLatestMainCiRun(unittest.TestCase):
|
||||
"""_latest_main_ci_run() must return only ci.yml push-to-main runs."""
|
||||
|
||||
def _ci_run(self, run_id, status="success"):
|
||||
return {"event": "push", "prettyref": "main", "workflow_id": "ci.yml",
|
||||
"status": status, "id": run_id}
|
||||
|
||||
def _deploy_run(self, run_id, status="success"):
|
||||
return {"event": "push", "prettyref": "main", "workflow_id": "deploy.yml",
|
||||
"status": status, "id": run_id}
|
||||
|
||||
def test_skips_deploy_run_returns_ci_run(self):
|
||||
# Forgejo reports deploy.yml schedule runs as event=push/prettyref=main;
|
||||
# must be excluded by workflow_id filter.
|
||||
runs = [self._deploy_run(1), self._ci_run(2)]
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||
result = agent_loop._latest_main_ci_run()
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["id"], 2)
|
||||
|
||||
def test_returns_none_when_only_deploy_runs_exist(self):
|
||||
runs = [self._deploy_run(1)]
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||
result = agent_loop._latest_main_ci_run()
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_none_when_only_schedule_runs_exist(self):
|
||||
runs = [{"event": "schedule", "prettyref": "main", "workflow_id": "deploy.yml",
|
||||
"status": "success", "id": 1}]
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||
result = agent_loop._latest_main_ci_run()
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_ci_push_to_main_run(self):
|
||||
runs = [self._ci_run(42, status="running")]
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||
result = agent_loop._latest_main_ci_run()
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["id"], 42)
|
||||
|
||||
|
||||
class TestLatestCiRunForBranch(unittest.TestCase):
|
||||
"""Tests for _latest_ci_run_for_branch — Forgejo API field mapping."""
|
||||
|
||||
@@ -667,5 +745,194 @@ class TestRunLoopResumeCommand(unittest.TestCase):
|
||||
self.assertNotIn("Resume:", output)
|
||||
|
||||
|
||||
|
||||
class TestCatchupSkipsQuestionIssues(unittest.TestCase):
|
||||
"""Catch-up must not retry merging a PR whose issue is already State/Question."""
|
||||
|
||||
def _make_pr(self, pr_number=50, branch="issue-10-fix"):
|
||||
return {"number": pr_number, "head": {"ref": branch}}
|
||||
|
||||
def test_skips_merge_when_issue_has_question_label(self):
|
||||
pr = self._make_pr()
|
||||
ci_run = {"id": 999, "status": "success"}
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[pr]), \
|
||||
patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \
|
||||
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
|
||||
patch("agent_loop._merge_pr") as mock_merge, \
|
||||
patch("agent_loop._comment_issue") as mock_comment, \
|
||||
patch("agent_loop._set_labels") as mock_labels, \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[]):
|
||||
result = agent_loop._run_loop()
|
||||
self.assertEqual(result, 0)
|
||||
mock_merge.assert_not_called()
|
||||
mock_comment.assert_not_called()
|
||||
mock_labels.assert_not_called()
|
||||
|
||||
def test_proceeds_with_merge_when_issue_lacks_question_label(self):
|
||||
pr = self._make_pr()
|
||||
ci_run = {"id": 999, "status": "success"}
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[pr]), \
|
||||
patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \
|
||||
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_IN_PROGRESS]), \
|
||||
patch("agent_loop._merge_pr") as mock_merge, \
|
||||
patch("agent_loop._find_pr_for_branch", return_value=None), \
|
||||
patch("agent_loop._close_issue"):
|
||||
result = agent_loop._run_loop()
|
||||
self.assertEqual(result, 0)
|
||||
mock_merge.assert_called_once_with(50)
|
||||
|
||||
|
||||
class TestMergeFailsOpen(unittest.TestCase):
|
||||
"""Tests for auto-resolution when a PR is still open after the merge command."""
|
||||
|
||||
def _dead_state(self, issue: int, kind: str = "issue") -> dict:
|
||||
return {
|
||||
"pid": 999999999,
|
||||
"issue": issue,
|
||||
"started_at": "2026-01-01T00:00:00+00:00",
|
||||
"type": kind,
|
||||
}
|
||||
|
||||
def _open_pr(self, branch: str = "issue-10-fix") -> dict:
|
||||
return {"number": 5, "head": {"ref": branch}, "created_at": "2026-01-01T00:00:00+00:00"}
|
||||
|
||||
def test_merge_fails_open_with_conflicts_spawns_rebase_agent(self):
|
||||
"""mergeable=false → rebase agent spawned, state written as pending-ci."""
|
||||
written_state = {}
|
||||
|
||||
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
|
||||
written_state["pid"] = pid
|
||||
written_state["issue"] = issue
|
||||
written_state["kind"] = kind
|
||||
written_state["session_name"] = session_name
|
||||
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), self._open_pr()]), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._merge_pr"), \
|
||||
patch("agent_loop._tea_get", return_value={"mergeable": False}), \
|
||||
patch("agent_loop._start_agent", return_value=77) as mock_start, \
|
||||
patch("agent_loop._write_state", side_effect=fake_write_state), \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_start.assert_called_once()
|
||||
prompt = mock_start.call_args[0][0]
|
||||
self.assertIn("Rebase branch", prompt)
|
||||
self.assertIn("issue-10-fix", prompt)
|
||||
self.assertEqual(written_state.get("kind"), "pending-ci")
|
||||
self.assertEqual(written_state.get("issue"), 10)
|
||||
|
||||
def test_merge_fails_open_no_conflicts_retries_and_succeeds(self):
|
||||
"""mergeable=true, second attempt succeeds → issue closed."""
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch",
|
||||
side_effect=[self._open_pr(), self._open_pr(), None]), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._merge_pr"), \
|
||||
patch("agent_loop._tea_get", return_value={"mergeable": True}), \
|
||||
patch("agent_loop.time.sleep"), \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_close.assert_called_once_with(10)
|
||||
|
||||
def test_merge_fails_open_no_conflicts_all_retries_exhausted(self):
|
||||
"""All retries exhausted with PR still open → falls through to State/Question."""
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch",
|
||||
side_effect=[self._open_pr(), self._open_pr(),
|
||||
self._open_pr(), self._open_pr()]), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._merge_pr"), \
|
||||
patch("agent_loop._tea_get", return_value={"mergeable": True}), \
|
||||
patch("agent_loop.time.sleep"), \
|
||||
patch("agent_loop._set_labels") as mock_labels, \
|
||||
patch("agent_loop._comment_issue") as mock_comment, \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_close.assert_not_called()
|
||||
mock_labels.assert_called_once_with(
|
||||
10,
|
||||
add=[agent_loop.LABEL_QUESTION],
|
||||
remove=[agent_loop.LABEL_IN_PROGRESS],
|
||||
)
|
||||
mock_comment.assert_called_once()
|
||||
|
||||
|
||||
class TestHeartbeat(unittest.TestCase):
|
||||
"""Tests for _update_heartbeat() and cmd_monitor()."""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".heartbeat")
|
||||
self._tmp.close()
|
||||
self._orig = agent_loop.HEARTBEAT_FILE
|
||||
agent_loop.HEARTBEAT_FILE = Path(self._tmp.name)
|
||||
Path(self._tmp.name).unlink() # Start with no heartbeat file.
|
||||
|
||||
def tearDown(self):
|
||||
agent_loop.HEARTBEAT_FILE = self._orig
|
||||
Path(self._tmp.name).unlink(missing_ok=True)
|
||||
|
||||
def test_update_heartbeat_writes_timestamp(self):
|
||||
agent_loop._update_heartbeat()
|
||||
content = Path(self._tmp.name).read_text().strip()
|
||||
dt = datetime.fromisoformat(content)
|
||||
age = (datetime.now(timezone.utc) - dt).total_seconds()
|
||||
self.assertLess(age, 5)
|
||||
|
||||
def test_update_heartbeat_creates_file(self):
|
||||
self.assertFalse(Path(self._tmp.name).exists())
|
||||
agent_loop._update_heartbeat()
|
||||
self.assertTrue(Path(self._tmp.name).exists())
|
||||
|
||||
def test_monitor_healthy_when_recent(self):
|
||||
agent_loop._update_heartbeat()
|
||||
result = agent_loop.cmd_monitor()
|
||||
self.assertEqual(result, 0)
|
||||
|
||||
def test_monitor_warns_when_heartbeat_missing(self):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
result = agent_loop.cmd_monitor()
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn("WARNING", buf.getvalue())
|
||||
|
||||
def test_monitor_warns_when_stale(self):
|
||||
stale = (datetime.now(timezone.utc) - timedelta(hours=3)).isoformat()
|
||||
Path(self._tmp.name).write_text(stale)
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
result = agent_loop.cmd_monitor()
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn("WARNING", buf.getvalue())
|
||||
|
||||
def test_monitor_warns_when_corrupted(self):
|
||||
Path(self._tmp.name).write_text("not-a-timestamp")
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
result = agent_loop.cmd_monitor()
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn("WARNING", buf.getvalue())
|
||||
|
||||
def test_run_loop_updates_heartbeat(self):
|
||||
self.assertFalse(Path(self._tmp.name).exists())
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[]):
|
||||
agent_loop._run_loop()
|
||||
self.assertTrue(Path(self._tmp.name).exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -289,7 +289,7 @@ class _FakeLogs implements SyncLogRepository {
|
||||
required bool success,
|
||||
String? errorMessage,
|
||||
String? stackTrace,
|
||||
bool? isPermanent,
|
||||
bool isPermanent = false,
|
||||
required String protocol,
|
||||
required int emailsFetched,
|
||||
required int emailsSkipped,
|
||||
|
||||
@@ -182,7 +182,7 @@ class FakeSyncLogRepository implements SyncLogRepository {
|
||||
required bool success,
|
||||
String? errorMessage,
|
||||
String? stackTrace,
|
||||
bool? isPermanent,
|
||||
bool isPermanent = false,
|
||||
required String protocol,
|
||||
required int emailsFetched,
|
||||
required int emailsSkipped,
|
||||
|
||||
@@ -194,9 +194,10 @@ void main() {
|
||||
// v32: local_sieve_applied table.
|
||||
await db.customSelect('SELECT count(*) FROM local_sieve_applied').get();
|
||||
|
||||
// v33: stack_trace and is_permanent columns on sync_logs.
|
||||
// v33: error_stack_trace and is_permanent columns on sync_logs.
|
||||
final syncLogColumns = await _tableColumns(db, 'sync_logs');
|
||||
expect(syncLogColumns, containsAll(['stack_trace', 'is_permanent']));
|
||||
expect(syncLogColumns, contains('error_stack_trace'));
|
||||
expect(syncLogColumns, contains('is_permanent'));
|
||||
|
||||
await db.close();
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
@@ -385,9 +386,10 @@ void main() {
|
||||
await _tableColumns(db, 'sync_log_mailboxes');
|
||||
expect(syncLogMailboxColumns, contains('duration_ms'));
|
||||
|
||||
// v33: stack_trace and is_permanent columns on sync_logs.
|
||||
// v33: error_stack_trace and is_permanent columns on sync_logs.
|
||||
final syncLogColumns = await _tableColumns(db, 'sync_logs');
|
||||
expect(syncLogColumns, containsAll(['stack_trace', 'is_permanent']));
|
||||
expect(syncLogColumns, contains('error_stack_trace'));
|
||||
expect(syncLogColumns, contains('is_permanent'));
|
||||
|
||||
await db.close();
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
@@ -434,9 +436,10 @@ void main() {
|
||||
await _tableColumns(db, 'sync_log_mailboxes');
|
||||
expect(syncLogMailboxColumns, contains('duration_ms'));
|
||||
|
||||
// v33: stack_trace and is_permanent columns on sync_logs.
|
||||
// v33: error_stack_trace and is_permanent columns on sync_logs.
|
||||
final syncLogColumns = await _tableColumns(db, 'sync_logs');
|
||||
expect(syncLogColumns, containsAll(['stack_trace', 'is_permanent']));
|
||||
expect(syncLogColumns, contains('error_stack_trace'));
|
||||
expect(syncLogColumns, contains('is_permanent'));
|
||||
|
||||
await db.close();
|
||||
});
|
||||
|
||||
@@ -171,7 +171,7 @@ class _FakeSyncLog implements SyncLogRepository {
|
||||
required bool success,
|
||||
String? errorMessage,
|
||||
String? stackTrace,
|
||||
bool? isPermanent,
|
||||
bool isPermanent = false,
|
||||
required String protocol,
|
||||
required int emailsFetched,
|
||||
required int emailsSkipped,
|
||||
|
||||
@@ -127,16 +127,18 @@ void main() {
|
||||
expect(rows.first.errorMessage, 'Connection refused');
|
||||
});
|
||||
|
||||
test('stores and retrieves stack trace and isPermanent', () async {
|
||||
test('stores and retrieves stackTrace and isPermanent on error entries',
|
||||
() async {
|
||||
final repo = SyncLogRepositoryImpl(db);
|
||||
final start = DateTime(2024, 3, 1, 10);
|
||||
final end = DateTime(2024, 3, 1, 10, 0, 1);
|
||||
final start = DateTime(2024, 3, 1, 9);
|
||||
final end = DateTime(2024, 3, 1, 9, 0, 1);
|
||||
const fakeTrace = '#0 main (file:///app/lib/main.dart:10:5)';
|
||||
|
||||
await repo.log(
|
||||
accountId: 'acc1',
|
||||
success: false,
|
||||
errorMessage: 'MissingPluginException',
|
||||
stackTrace: '#0 MethodChannel._invokeMethod\n#1 main',
|
||||
stackTrace: fakeTrace,
|
||||
isPermanent: true,
|
||||
protocol: 'imap',
|
||||
emailsFetched: 0,
|
||||
@@ -149,33 +151,9 @@ void main() {
|
||||
);
|
||||
|
||||
final entries = await repo.observeSyncLogs('acc1').first;
|
||||
final latest = entries.firstWhere((e) => e.startedAt == start);
|
||||
expect(latest.stackTrace, '#0 MethodChannel._invokeMethod\n#1 main');
|
||||
expect(latest.isPermanent, isTrue);
|
||||
expect(latest.errorMessage, 'MissingPluginException');
|
||||
});
|
||||
|
||||
test('isPermanent is null for success entries', () async {
|
||||
final repo = SyncLogRepositoryImpl(db);
|
||||
final start = DateTime(2024, 4, 1, 10);
|
||||
final end = DateTime(2024, 4, 1, 10, 0, 3);
|
||||
|
||||
await repo.log(
|
||||
accountId: 'acc1',
|
||||
success: true,
|
||||
protocol: 'imap',
|
||||
emailsFetched: 2,
|
||||
emailsSkipped: 0,
|
||||
mailboxesSynced: 1,
|
||||
pendingFlushed: 0,
|
||||
bytesTransferred: 0,
|
||||
startedAt: start,
|
||||
finishedAt: end,
|
||||
);
|
||||
|
||||
final entries = await repo.observeSyncLogs('acc1').first;
|
||||
final latest = entries.firstWhere((e) => e.startedAt == start);
|
||||
expect(latest.isPermanent, isNull);
|
||||
expect(latest.stackTrace, isNull);
|
||||
final entry = entries.firstWhere((e) => e.startedAt == start);
|
||||
expect(entry.stackTrace, fakeTrace);
|
||||
expect(entry.isPermanent, true);
|
||||
expect(entry.errorMessage, 'MissingPluginException');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,6 +27,22 @@ class MockUrlLauncher extends Mock
|
||||
}
|
||||
}
|
||||
|
||||
class ThrowingUrlLauncher extends Mock
|
||||
with MockPlatformInterfaceMixin
|
||||
implements UrlLauncherPlatform {
|
||||
@override
|
||||
Future<bool> canLaunch(String? url) async => true;
|
||||
|
||||
@override
|
||||
Future<bool> launchUrl(String? url, LaunchOptions? options) async {
|
||||
throw PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'Unable to establish connection on channel: '
|
||||
'"dev.flutter.pigeon.url_launcher_android.UrlLauncherApi.launchUrl".',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildScreen({List<Account> accounts = const []}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
@@ -64,6 +80,9 @@ void main() {
|
||||
expect(find.textContaining('Dark Mode'), findsWidgets);
|
||||
expect(find.textContaining('IMAP Accounts'), findsWidgets);
|
||||
expect(find.textContaining('JMAP Accounts'), findsWidgets);
|
||||
expect(find.textContaining('Locale'), findsWidgets);
|
||||
expect(find.textContaining('Text Scale'), findsWidgets);
|
||||
expect(find.textContaining('DB Schema Version'), findsWidgets);
|
||||
// Buttons are in the body, not in the AppBar actions
|
||||
expect(find.byIcon(Icons.copy), findsOneWidget);
|
||||
expect(find.byIcon(Icons.bug_report), findsOneWidget);
|
||||
@@ -151,6 +170,9 @@ void main() {
|
||||
expect(clipboardText, contains('Dark Mode'));
|
||||
expect(clipboardText, contains('IMAP Accounts'));
|
||||
expect(clipboardText, contains('JMAP Accounts'));
|
||||
expect(clipboardText, contains('Locale'));
|
||||
expect(clipboardText, contains('Text Scale'));
|
||||
expect(clipboardText, contains('DB Schema Version'));
|
||||
expect(
|
||||
clipboardText,
|
||||
contains('[sharedinbox.de](https://sharedinbox.de)'),
|
||||
@@ -180,4 +202,24 @@ void main() {
|
||||
);
|
||||
expect(mock.launchedUrl, contains('1.2.3%2B99'));
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'AboutScreen link tap with failed url_launcher shows error snackbar',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
UrlLauncherPlatform.instance = ThrowingUrlLauncher();
|
||||
|
||||
await tester.pumpWidget(_buildScreen());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.textContaining('sharedinbox.de').first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Error:'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ void main() {
|
||||
expect(find.byKey(const Key('scanEncryptedButton')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows 20-minute expiry hint', (tester) async {
|
||||
testWidgets('shows expiry countdown hint', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/receive',
|
||||
@@ -32,8 +32,106 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('20 minutes'), findsOneWidget);
|
||||
expect(find.textContaining('expires in'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'step 2 button shows text-input fallback on platforms without camera',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/receive',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('scanEncryptedButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// On Linux (desktop, no camera) the text fallback field must appear.
|
||||
expect(find.byKey(const Key('encryptedCodeField')), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'step 2 — valid encrypted QR imports account via text fallback',
|
||||
(tester) async {
|
||||
// Pre-generate a key pair so we can encrypt a QR code with the same
|
||||
// material the screen will use for decryption.
|
||||
final material = await ShareEncryptionService.generateKeyPair();
|
||||
final repo = FakeShareKeyRepository(material: material);
|
||||
|
||||
const account = Account(
|
||||
id: 'src-1',
|
||||
displayName: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
imapHost: 'imap.example.com',
|
||||
smtpHost: 'smtp.example.com',
|
||||
);
|
||||
|
||||
final encryptedQr = await ShareEncryptionService.encryptAccounts(
|
||||
recipientKeyId: material.keyId,
|
||||
recipientPublicKeyBytes: material.publicKeyBytes,
|
||||
accounts: [
|
||||
AccountPayload(
|
||||
accountJson: account.toJson(),
|
||||
password: 'secret',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/receive',
|
||||
overrides: baseOverrides(shareKeyRepository: repo),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(); // key generation completes
|
||||
|
||||
await tester.tap(find.byKey(const Key('scanEncryptedButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('encryptedCodeField')),
|
||||
encryptedQr,
|
||||
);
|
||||
await tester.tap(find.text('Import'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('Imported 1 account successfully.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'step 2 — invalid encrypted QR shows error and returns to pub-key step',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/receive',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('scanEncryptedButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('encryptedCodeField')),
|
||||
'not-a-valid-qr-code',
|
||||
);
|
||||
await tester.tap(find.text('Import'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Screen returns to the pub-key step with an error message visible.
|
||||
expect(find.byKey(const Key('pubKeyQrCode')), findsOneWidget);
|
||||
expect(find.textContaining('Import failed:'), findsWidgets);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('AccountSendScreen', () {
|
||||
|
||||
@@ -1,40 +1,41 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
|
||||
|
||||
class _FakeAssetBundle extends Fake implements AssetBundle {
|
||||
final String content;
|
||||
_FakeAssetBundle(this.content);
|
||||
|
||||
@override
|
||||
Future<String> loadString(String key, {bool cache = true}) async {
|
||||
if (key == 'assets/changelog.txt') return content;
|
||||
throw FlutterError('Asset not found: $key');
|
||||
}
|
||||
class _FakeAssetBundle extends CachingAssetBundle {
|
||||
final Map<String, String> _assets;
|
||||
_FakeAssetBundle(this._assets);
|
||||
|
||||
@override
|
||||
Future<ByteData> load(String key) async {
|
||||
throw FlutterError('Asset not found: $key');
|
||||
if (_assets.containsKey(key)) {
|
||||
final encoded = utf8.encode(_assets[key]!);
|
||||
return ByteData.view(Uint8List.fromList(encoded).buffer);
|
||||
}
|
||||
throw FlutterError('Asset not found: "$key"');
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('ChangeLogScreen renders changelog content', (tester) async {
|
||||
const fakeChangelog =
|
||||
'* 2026-01-01: Initial release\n* 2026-01-02: Bug fix';
|
||||
const _fakeChangelog =
|
||||
'* 2024-01-01 feat: initial release\n* 2024-01-02 fix: resolve crash\n';
|
||||
|
||||
void main() {
|
||||
testWidgets('ChangeLogScreen shows changelog content', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
DefaultAssetBundle(
|
||||
bundle: _FakeAssetBundle(fakeChangelog),
|
||||
bundle: _FakeAssetBundle({'assets/changelog.txt': _fakeChangelog}),
|
||||
child: const MaterialApp(home: ChangeLogScreen()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('ChangeLog'), findsOneWidget);
|
||||
expect(find.textContaining('2026-01-01'), findsOneWidget);
|
||||
expect(find.textContaining('Initial release'), findsOneWidget);
|
||||
expect(find.textContaining('initial release'), findsOneWidget);
|
||||
expect(find.textContaining('resolve crash'), findsOneWidget);
|
||||
expect(find.textContaining('Error loading changelog'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('ChangeLogScreen shows error when asset is missing', (
|
||||
@@ -42,24 +43,12 @@ void main() {
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
DefaultAssetBundle(
|
||||
bundle: _BadAssetBundle(),
|
||||
bundle: _FakeAssetBundle({}),
|
||||
child: const MaterialApp(home: ChangeLogScreen()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Error loading changelog:'), findsOneWidget);
|
||||
expect(find.textContaining('Error loading changelog'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
class _BadAssetBundle extends Fake implements AssetBundle {
|
||||
@override
|
||||
Future<String> loadString(String key, {bool cache = true}) async {
|
||||
throw FlutterError('Unable to load asset: "$key"');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ByteData> load(String key) async {
|
||||
throw FlutterError('Unable to load asset: "$key"');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,10 @@ void main() {
|
||||
|
||||
expect(clipboardText, isNotNull);
|
||||
expect(clipboardText, contains('App Version: 1.0.0+42'));
|
||||
expect(clipboardText, contains('Build Mode:'));
|
||||
expect(clipboardText, contains('Platform:'));
|
||||
expect(clipboardText, contains('Dart:'));
|
||||
expect(clipboardText, contains('Timestamp:'));
|
||||
expect(clipboardText, contains('TestException: clipboard test'));
|
||||
// GIT_HASH is empty in test builds — no Git Commit line expected
|
||||
expect(clipboardText, isNot(contains('Git Commit:')));
|
||||
@@ -144,6 +147,7 @@ void main() {
|
||||
gitHash: testHash,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Git hash link should be present
|
||||
final gitLinkFinder = find.textContaining('Git Commit: abc1234');
|
||||
@@ -167,6 +171,138 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'CrashScreen shows version, build mode, and platform in the UI',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
const exception = 'TestException: info row test';
|
||||
final stackTrace = StackTrace.current;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: CrashScreen(exception: exception, stackTrace: stackTrace),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Info row shows app version (from mock), build mode, and platform OS.
|
||||
expect(find.textContaining('1.0.0+42'), findsWidgets);
|
||||
// In test builds kDebugMode is true.
|
||||
expect(find.textContaining('debug'), findsOneWidget);
|
||||
// Platform OS is always present (linux in CI, android/ios on device).
|
||||
expect(
|
||||
find.textContaining(RegExp(r'linux|android|ios|windows|macos')),
|
||||
findsWidgets,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'CrashScreen shows app version as clickable link when git hash is set',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
final mock = MockUrlLauncher();
|
||||
UrlLauncherPlatform.instance = mock;
|
||||
|
||||
const exception = 'TestException: version link test';
|
||||
final stackTrace = StackTrace.current;
|
||||
const testHash = 'abc1234';
|
||||
|
||||
await tester.pumpWidget(
|
||||
CrashScreen(
|
||||
exception: exception,
|
||||
stackTrace: stackTrace,
|
||||
gitHash: testHash,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// App version link should be present (mocked as 1.0.0+42)
|
||||
final versionLinkFinder = find.textContaining('App Version: 1.0.0+42');
|
||||
expect(versionLinkFinder, findsOneWidget);
|
||||
|
||||
// It must appear above the git hash link
|
||||
final gitLinkFinder = find.textContaining('Git Commit: abc1234');
|
||||
expect(
|
||||
tester.getTopLeft(versionLinkFinder).dy,
|
||||
lessThan(tester.getTopLeft(gitLinkFinder).dy),
|
||||
);
|
||||
|
||||
// Tapping it should open the Codeberg commit URL
|
||||
await tester.tap(versionLinkFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
mock.launchedUrl,
|
||||
equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'CrashScreen copy-to-clipboard includes app version as markdown link when git hash is set',
|
||||
(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: version link clipboard test';
|
||||
final stackTrace = StackTrace.current;
|
||||
const testHash = 'abc1234';
|
||||
|
||||
await tester.pumpWidget(
|
||||
CrashScreen(
|
||||
exception: exception,
|
||||
stackTrace: stackTrace,
|
||||
gitHash: testHash,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Copy to Clipboard'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(clipboardText, isNotNull);
|
||||
// App Version must be a markdown link pointing to the commit
|
||||
expect(
|
||||
clipboardText,
|
||||
contains(
|
||||
'App Version: [1.0.0+42](https://codeberg.org/guettli/sharedinbox/commit/abc1234)',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
clipboardText,
|
||||
contains(
|
||||
'Git Commit: [abc1234](https://codeberg.org/guettli/sharedinbox/commit/abc1234)',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'CrashScreen used as root widget — buttons work without ScaffoldMessenger crash',
|
||||
(tester) async {
|
||||
|
||||
@@ -105,6 +105,88 @@ void main() {
|
||||
expect(find.text('Edit account'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'try connection button is disabled when no password stored or entered',
|
||||
(
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(800, 1400);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/edit',
|
||||
overrides: baseOverrides(
|
||||
accounts: [kTestAccount],
|
||||
hasStoredPassword: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final button = tester.widget<OutlinedButton>(
|
||||
find.byKey(const Key('editTryConnectionButton')),
|
||||
);
|
||||
expect(button.onPressed, isNull);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'try connection button is enabled after typing password with no stored password',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1400);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/edit',
|
||||
overrides: baseOverrides(
|
||||
accounts: [kTestAccount],
|
||||
hasStoredPassword: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('editPasswordField')),
|
||||
'mypassword',
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final button = tester.widget<OutlinedButton>(
|
||||
find.byKey(const Key('editTryConnectionButton')),
|
||||
);
|
||||
expect(button.onPressed, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('save button is disabled when no password stored or entered', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(800, 1400);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/edit',
|
||||
overrides: baseOverrides(
|
||||
accounts: [kTestAccount],
|
||||
hasStoredPassword: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final button = tester
|
||||
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Save'));
|
||||
expect(button.onPressed, isNull);
|
||||
});
|
||||
|
||||
testWidgets('connection error shows error message', (tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1400);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
|
||||
@@ -179,6 +179,142 @@ void main() {
|
||||
expect(find.text('report.pdf'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Reply All button is not present in app bar', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||
overrides: _overrides(
|
||||
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Tooltip && w.message == 'Reply all',
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('Reply on single-recipient email navigates directly to compose',
|
||||
(tester) async {
|
||||
// testEmail has from=[bob], to=[alice]. After removing alice (own),
|
||||
// only bob remains → no dialog, navigate straight to compose.
|
||||
final email = testEmail();
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||
overrides: [
|
||||
..._overrides(
|
||||
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||
email: email,
|
||||
),
|
||||
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Tooltip && w.message == 'Reply',
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// No dialog shown — straight navigation to compose.
|
||||
expect(find.text('Reply All'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Reply on multi-recipient email shows Reply All dialog',
|
||||
(tester) async {
|
||||
// Email with an extra Cc recipient so the dialog is triggered.
|
||||
final email = Email(
|
||||
id: 'acc-1:42',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 42,
|
||||
subject: 'Hello world',
|
||||
receivedAt: DateTime(2024, 6),
|
||||
sentAt: DateTime(2024, 6),
|
||||
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
|
||||
to: const [EmailAddress(email: 'alice@example.com')],
|
||||
cc: const [EmailAddress(name: 'Carol', email: 'carol@example.com')],
|
||||
isSeen: false,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||
overrides: _overrides(
|
||||
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||
email: email,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Tooltip && w.message == 'Reply',
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Dialog must appear with title 'Reply All'.
|
||||
expect(find.text('Reply All'), findsOneWidget);
|
||||
// Both non-own addresses should be listed in the dialog.
|
||||
expect(find.textContaining('bob@example.com'), findsAtLeastNWidgets(1));
|
||||
expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1));
|
||||
});
|
||||
|
||||
testWidgets('Mark as spam button is present in app bar', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||
overrides: _overrides(
|
||||
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Tooltip && w.message == 'Mark as spam',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'Mark as spam moves email to junk and shows snackbar when no junk folder',
|
||||
(tester) async {
|
||||
// FakeMailboxRepository has no mailboxes by default → findMailboxByRole
|
||||
// returns null → snackbar shown.
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||
overrides: _overrides(
|
||||
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Tooltip && w.message == 'Mark as spam',
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('No Junk folder found'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Show Raw Email dialog shows size of email', (tester) async {
|
||||
// 'A' * 2048 → fmtSize(2048) == '2.0 KB'
|
||||
final rawContent = 'A' * 2048;
|
||||
|
||||
@@ -44,11 +44,12 @@ import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class FakeAccountRepository implements AccountRepository {
|
||||
final List<Account> _accounts;
|
||||
|
||||
FakeAccountRepository([List<Account>? accounts])
|
||||
: _accounts = List.of(accounts ?? []);
|
||||
|
||||
final List<Account> _accounts;
|
||||
bool hasPassword = true;
|
||||
|
||||
@override
|
||||
Stream<List<Account>> observeAccounts() => Stream.value(List.of(_accounts));
|
||||
|
||||
@@ -75,15 +76,22 @@ class FakeAccountRepository implements AccountRepository {
|
||||
_accounts.removeWhere((a) => a.id == id);
|
||||
|
||||
@override
|
||||
Future<String> getPassword(String accountId) async => 'test-password';
|
||||
Future<String> getPassword(String accountId) async {
|
||||
if (!hasPassword) {
|
||||
throw StateError('No password stored for account $accountId');
|
||||
}
|
||||
return 'test-password';
|
||||
}
|
||||
}
|
||||
|
||||
class FakeShareKeyRepository implements ShareKeyRepository {
|
||||
FakeShareKeyRepository({ShareKeyMaterial? material}) : _material = material;
|
||||
|
||||
ShareKeyMaterial? _material;
|
||||
|
||||
@override
|
||||
Future<ShareKeyMaterial> createKeyPair() async {
|
||||
_material = await ShareEncryptionService.generateKeyPair();
|
||||
_material ??= await ShareEncryptionService.generateKeyPair();
|
||||
return _material!;
|
||||
}
|
||||
|
||||
@@ -511,10 +519,13 @@ List<Override> baseOverrides({
|
||||
List<Mailbox>? mailboxes,
|
||||
DiscoveryResult? discovery,
|
||||
Exception? connectionError,
|
||||
ShareKeyRepository? shareKeyRepository,
|
||||
bool hasStoredPassword = true,
|
||||
}) =>
|
||||
[
|
||||
accountRepositoryProvider
|
||||
.overrideWithValue(FakeAccountRepository(accounts)),
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository(accounts)..hasPassword = hasStoredPassword,
|
||||
),
|
||||
mailboxRepositoryProvider
|
||||
.overrideWithValue(FakeMailboxRepository(mailboxes)),
|
||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||
@@ -525,7 +536,9 @@ List<Override> baseOverrides({
|
||||
connectionTestServiceProvider.overrideWithValue(
|
||||
FakeConnectionTestService(error: connectionError),
|
||||
),
|
||||
shareKeyRepositoryProvider.overrideWithValue(FakeShareKeyRepository()),
|
||||
shareKeyRepositoryProvider.overrideWithValue(
|
||||
shareKeyRepository ?? FakeShareKeyRepository(),
|
||||
),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user