Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a167ebd0a3 | ||
|
|
881a240155 | ||
|
|
117b546b2c | ||
|
|
d9b8748631 | ||
|
|
50ae7df8a3 | ||
|
|
7dd5800064 | ||
|
|
77e581299d | ||
|
|
37eca207c6 | ||
|
|
5925cee4f2 | ||
|
|
a8603edfc3 |
@@ -156,6 +156,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 }}
|
||||
@@ -197,6 +198,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 +240,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"
|
||||
|
||||
@@ -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/"
|
||||
|
||||
+41
-16
@@ -215,8 +215,10 @@ 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
|
||||
@@ -251,17 +253,24 @@ 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"
|
||||
- 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)
|
||||
@@ -373,25 +382,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 +429,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 +600,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 +640,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/
|
||||
|
||||
|
||||
+21
-14
@@ -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.
|
||||
@@ -318,12 +319,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 +516,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 +528,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 +541,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 +562,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",
|
||||
@@ -589,6 +594,7 @@ 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,
|
||||
@@ -599,11 +605,11 @@ func (m *Ci) DeployLinux(
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -626,6 +632,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,
|
||||
@@ -639,10 +646,10 @@ func (m *Ci) DeployApk(
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -804,7 +811,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"])
|
||||
|
||||
@@ -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';
|
||||
@@ -10,27 +11,42 @@ class CrashScreen extends StatelessWidget {
|
||||
super.key,
|
||||
required this.exception,
|
||||
required this.stackTrace,
|
||||
this.gitHash = const String.fromEnvironment('GIT_HASH'),
|
||||
});
|
||||
|
||||
final Object exception;
|
||||
final StackTrace? stackTrace;
|
||||
final String gitHash;
|
||||
|
||||
static const _gitHash = String.fromEnvironment('GIT_HASH');
|
||||
String get _buildMode {
|
||||
if (kDebugMode) return 'debug';
|
||||
if (kProfileMode) return 'profile';
|
||||
return 'release';
|
||||
}
|
||||
|
||||
Future<String> _buildReport() async {
|
||||
String version = 'unknown';
|
||||
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 gitLine = _gitHash.isNotEmpty
|
||||
? 'Git Commit: [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)\n'
|
||||
final gitLine = gitHash.isNotEmpty
|
||||
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
|
||||
: '';
|
||||
final timestamp = DateTime.now().toUtc().toIso8601String();
|
||||
return 'App Version: $version\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,13 +72,40 @@ class CrashScreen extends StatelessWidget {
|
||||
style: Theme.of(ctx).textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (_gitHash.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Git Commit: $_gitHash',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
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),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
final url = Uri.parse(
|
||||
'https://codeberg.org/guettli/sharedinbox/commit/$gitHash',
|
||||
);
|
||||
await launchUrl(
|
||||
url,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
'Git Commit: $gitHash',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
@@ -106,32 +149,6 @@ class CrashScreen extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_gitHash.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Git Commit:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
final url = Uri.parse(
|
||||
'https://codeberg.org/guettli/sharedinbox/commit/$_gitHash',
|
||||
);
|
||||
await launchUrl(
|
||||
url,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
child: const Text(
|
||||
_gitHash,
|
||||
style: TextStyle(
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: () async {
|
||||
|
||||
+109
-19
@@ -8,12 +8,15 @@ Flow
|
||||
a. Age > 1 h → kill it, set its issue to State/Question, exit 1
|
||||
b. Age ≤ 1 h → print status, exit 0 (let it keep working)
|
||||
2. No agent running → extract pending_issue from state (if any), then check CI
|
||||
a. CI is running → save pending-ci state, exit 0
|
||||
b. Latest CI failed → start fix-CI agent (preserving pending_issue), exit 0
|
||||
c. CI ok + pending_issue → close the issue (CI passed), exit 0
|
||||
d. CI ok (or no run yet) → find oldest Ready issue, start issue agent,
|
||||
save state, exit 0
|
||||
e. No Ready issues → print "nothing to do", exit 0
|
||||
a. pending_issue + open PR → check PR branch CI, merge/fix/wait as needed
|
||||
b. Catch-up: orphaned issue-N-fix PRs with passing CI → merge them
|
||||
c. Main CI running → save pending-ci state, exit 0
|
||||
d. Main CI failed → start fix-CI agent (pushes fix to main), exit 0
|
||||
e. Main CI ok + pending_issue → close the issue, exit 0 (dead code path —
|
||||
section 2a always returns first)
|
||||
f. Main CI ok (or no run yet) → find oldest Ready issue, start issue agent,
|
||||
save state, exit 0
|
||||
g. No Ready issues → print "nothing to do", exit 0
|
||||
|
||||
Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
|
||||
|
||||
@@ -142,10 +145,21 @@ def _ready_issues() -> list[dict]:
|
||||
return ready
|
||||
|
||||
|
||||
def _latest_ci_run() -> dict | None:
|
||||
data = _tea_get(f"repos/{REPO}/actions/runs?limit=1")
|
||||
def _latest_main_ci_run() -> dict | None:
|
||||
"""Return the latest ci.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", [])
|
||||
return runs[0] if runs else None
|
||||
for run in runs:
|
||||
if (run.get("event") == "push"
|
||||
and run.get("prettyref") == "main"
|
||||
and run.get("workflow_id") == "ci.yml"):
|
||||
return run
|
||||
return None
|
||||
|
||||
|
||||
def _latest_ci_run_for_branch(branch: str) -> dict | None:
|
||||
@@ -165,7 +179,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
|
||||
@@ -333,6 +347,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:
|
||||
@@ -367,11 +390,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 ───────────────────────────────────────────────────────────────
|
||||
@@ -509,6 +534,9 @@ def _run_loop() -> int:
|
||||
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
|
||||
"Identify the failure, fix it, commit, and push to the same branch. "
|
||||
"Do NOT push to main, do NOT close the issue, do NOT merge the PR. "
|
||||
"Do NOT reference any issue numbers in commit messages "
|
||||
"(no 'closes #N', 'fixes #N', or similar) — auto-closing the wrong "
|
||||
"issue via a commit message would be a bug. "
|
||||
"Verify locally with 'task check' before pushing. "
|
||||
"When done, stop."
|
||||
)
|
||||
@@ -547,7 +575,25 @@ def _run_loop() -> int:
|
||||
|
||||
# CI passed on the PR branch — squash-merge and close.
|
||||
print(f"CI passed {_ci_run_url(pr_run['id'])} on branch {branch!r} — merging PR #{pr_number}.")
|
||||
_merge_pr(pr_number)
|
||||
try:
|
||||
_merge_pr(pr_number)
|
||||
except RuntimeError as e:
|
||||
print(f"Merge of PR #{pr_number} failed: {e} — setting to State/Question.")
|
||||
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||
_comment_issue(
|
||||
pending_issue,
|
||||
f"Automatic merge of PR #{pr_number} failed: {e}. Please merge manually.",
|
||||
)
|
||||
return 0
|
||||
if _find_pr_for_branch(branch):
|
||||
print(f"PR #{pr_number} is still open after merge attempt — setting to State/Question.")
|
||||
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||
_comment_issue(
|
||||
pending_issue,
|
||||
f"Automatic merge of PR #{pr_number} failed (PR is still open after the "
|
||||
"merge command). Please merge manually.",
|
||||
)
|
||||
return 0
|
||||
_close_issue(pending_issue)
|
||||
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
|
||||
return 0
|
||||
@@ -597,7 +643,26 @@ def _run_loop() -> int:
|
||||
|
||||
if pr_run and pr_run.get("status") == "success":
|
||||
print(f"Catch-up: CI passed on PR #{pr_number} ({pr_url}) — merging.")
|
||||
_merge_pr(pr_number)
|
||||
try:
|
||||
_merge_pr(pr_number)
|
||||
except RuntimeError as e:
|
||||
print(f"Catch-up: merge of PR #{pr_number} failed: {e} — skipping.")
|
||||
continue
|
||||
# Verify the merge actually happened; fgj can exit 0 without merging
|
||||
# (e.g. branch-protection rules not satisfied).
|
||||
if _find_pr_for_branch(branch):
|
||||
print(
|
||||
f"Catch-up: PR #{pr_number} is still open after merge attempt "
|
||||
"— skipping to avoid infinite retry."
|
||||
)
|
||||
if issue_num:
|
||||
_set_labels(issue_num, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||
_comment_issue(
|
||||
issue_num,
|
||||
f"Automatic merge of PR #{pr_number} failed (PR is still open "
|
||||
"after the merge command). Please merge manually.",
|
||||
)
|
||||
continue
|
||||
if issue_num:
|
||||
_close_issue(issue_num)
|
||||
print(f"Merged PR #{pr_number} and closed issue #{issue_num}.")
|
||||
@@ -605,8 +670,8 @@ def _run_loop() -> int:
|
||||
print(f"Merged PR #{pr_number}.")
|
||||
return 0
|
||||
|
||||
# ── 3. Global CI check (agent pushed to main, or no pending issue) ────────
|
||||
run = _latest_ci_run()
|
||||
# ── 3. Global CI check (main branch only) ────────────────────────────────
|
||||
run = _latest_main_ci_run()
|
||||
|
||||
if run and run.get("status") == "running":
|
||||
print(f"CI run {_ci_run_url(run['id'])} is still running. Waiting.")
|
||||
@@ -615,17 +680,39 @@ def _run_loop() -> int:
|
||||
return 0
|
||||
|
||||
if run and run.get("status") in ("failure", "error"):
|
||||
# Guard: if the same main CI run has been failing since the last ci-fix
|
||||
# agent started, that agent pushed to a branch instead of main. Before
|
||||
# spawning another agent, check whether any CI run is currently in
|
||||
# progress (the branch run) and wait if so.
|
||||
if ci_run_id_at_start is not None and run["id"] == ci_run_id_at_start:
|
||||
check = _tea_get(f"repos/{REPO}/actions/runs?limit=5")
|
||||
in_flight = [
|
||||
r for r in (check or {}).get("workflow_runs", [])
|
||||
if r.get("status") == "running"
|
||||
]
|
||||
if in_flight:
|
||||
print(
|
||||
f"Main CI still shows the same failed run {run['id']}; "
|
||||
f"{_ci_run_url(in_flight[0]['id'])} is running "
|
||||
"(previous ci-fix pushed to a branch). Waiting."
|
||||
)
|
||||
return 0
|
||||
print(f"CI run {_ci_run_url(run['id'])} failed — starting fix agent.")
|
||||
prompt = (
|
||||
"The Codeberg CI for guettli/sharedinbox just failed. "
|
||||
"The Codeberg CI for guettli/sharedinbox just failed on the main branch. "
|
||||
f"The CI run ID is {run['id']}. "
|
||||
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
|
||||
"Identify the failure, fix it, commit, and push. "
|
||||
"Identify the failure, fix it, commit, and push directly to main. "
|
||||
"Verify locally with 'task check' before pushing. "
|
||||
"Do NOT reference any issue numbers in commit messages "
|
||||
"(no 'closes #N', 'fixes #N', or similar) — this is a CI fix, "
|
||||
"not an issue fix, and auto-closing an issue via a commit message would be a bug. "
|
||||
"Do NOT close any issues. "
|
||||
"When done, stop."
|
||||
)
|
||||
pid = _start_agent(prompt, "ci-fix")
|
||||
_write_state(pid, pending_issue, "ci-fix", session_name="ci-fix")
|
||||
_write_state(pid, pending_issue, "ci-fix", session_name="ci-fix",
|
||||
ci_run_id=run["id"] if run else None)
|
||||
return 0
|
||||
|
||||
# CI is ok (or no run).
|
||||
@@ -684,7 +771,10 @@ Instructions:
|
||||
- Implement the required change, following the existing code style.
|
||||
- Write or update tests as appropriate.
|
||||
- Run 'task check' locally and fix any failures before committing.
|
||||
- Commit with a descriptive message referencing the issue number (e.g. "feat: ... (#{issue_number})").
|
||||
- Commit with a descriptive message and include (#{issue_number}) in the title,
|
||||
e.g. "feat: description (#{issue_number})".
|
||||
Do NOT use "Closes #N" or "Fixes #N" keywords — the loop closes the issue
|
||||
after CI passes; using those keywords would close it prematurely or wrongly.
|
||||
- Create a branch named `issue-{issue_number}-fix`, push your changes there, and open a PR against main:
|
||||
git checkout -b issue-{issue_number}-fix
|
||||
git push -u origin issue-{issue_number}-fix
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
+94
-17
@@ -88,21 +88,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 +200,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 +227,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 +241,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 +261,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 +296,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 +313,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 +423,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 +440,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 +457,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 +469,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 +480,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 +491,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 +504,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."""
|
||||
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -116,13 +116,89 @@ 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:')));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'CrashScreen shows git hash as clickable link above stacktrace',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
final mock = MockUrlLauncher();
|
||||
UrlLauncherPlatform.instance = mock;
|
||||
|
||||
const exception = 'TestException: git hash test';
|
||||
final stackTrace = StackTrace.current;
|
||||
const testHash = 'abc1234';
|
||||
|
||||
await tester.pumpWidget(
|
||||
CrashScreen(
|
||||
exception: exception,
|
||||
stackTrace: stackTrace,
|
||||
gitHash: testHash,
|
||||
),
|
||||
);
|
||||
|
||||
// Git hash link should be present
|
||||
final gitLinkFinder = find.textContaining('Git Commit: abc1234');
|
||||
expect(gitLinkFinder, findsOneWidget);
|
||||
|
||||
// Link must appear above the stack trace
|
||||
final stackTraceFinder = find.text('Stack Trace:');
|
||||
expect(
|
||||
tester.getTopLeft(gitLinkFinder).dy,
|
||||
lessThan(tester.getTopLeft(stackTraceFinder).dy),
|
||||
);
|
||||
|
||||
// Tapping the link should open the Codeberg commit URL
|
||||
await tester.tap(gitLinkFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
mock.launchedUrl,
|
||||
equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'CrashScreen 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 used as root widget — buttons work without ScaffoldMessenger crash',
|
||||
(tester) async {
|
||||
|
||||
@@ -79,11 +79,13 @@ class FakeAccountRepository implements AccountRepository {
|
||||
}
|
||||
|
||||
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,6 +513,7 @@ List<Override> baseOverrides({
|
||||
List<Mailbox>? mailboxes,
|
||||
DiscoveryResult? discovery,
|
||||
Exception? connectionError,
|
||||
ShareKeyRepository? shareKeyRepository,
|
||||
}) =>
|
||||
[
|
||||
accountRepositoryProvider
|
||||
@@ -525,7 +528,9 @@ List<Override> baseOverrides({
|
||||
connectionTestServiceProvider.overrideWithValue(
|
||||
FakeConnectionTestService(error: connectionError),
|
||||
),
|
||||
shareKeyRepositoryProvider.overrideWithValue(FakeShareKeyRepository()),
|
||||
shareKeyRepositoryProvider.overrideWithValue(
|
||||
shareKeyRepository ?? FakeShareKeyRepository(),
|
||||
),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user