Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 d0973fafbf fix(agent_loop): prevent infinite catch-up merge retry and wrong issue closure
Two bugs fixed:

1. Catch-up scan (section 2b) called _merge_pr and immediately returned,
   claiming success even when fgj exits 0 but the merge silently failed
   (e.g. branch-protection rules not satisfied). PR #163 was retried 30+
   times in a row because the PR stayed open after each attempt.
   Fix: verify the PR is no longer open after the merge call; if it is still
   open, set the issue to State/Question instead of looping forever.

2. ci-fix agents wrote "Closes #198" in commit messages, causing Forgejo to
   auto-close issue #198 ("Unable to load asset: assets/changelog.txt") even
   though the commit only fixed the unrelated Play Store upload.
   Fix: both ci-fix prompts now explicitly forbid issue-number references in
   commit messages and close operations. Also save ci_run_id_at_start in
   the ci-fix state (was only done for issue agents) so future guard logic
   can compare run IDs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:48:00 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 7310568157 fix: merge orphaned issue PRs whose CI passed but state was cleared (#200)
Add catch-up scan in agent_loop that finds all open issue-N-fix PRs and
merges those with passed CI, using event-filtered API query (limit=50)
to cover weeks of history instead of the previous ~1.5 h window.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:49:33 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 a569177637 fix: treat MissingPluginException from secure storage as permanent sync error (#200)
When flutter_secure_storage's platform channel is unavailable (e.g. on
certain Android devices), getPassword() throws MissingPluginException.
Previously this was not recognised as a permanent error, so the IMAP and
JMAP sync loops retried indefinitely with exponential back-off, filling
the sync log with repeated failures (as shown in the screenshot).

Treat MissingPluginException as a permanent error in both _AccountSync
and _JmapAccountSync so the loop stops immediately instead of retrying.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:46:29 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 375fd5d914 ci: skip jobs when unrelated files change, skip Android/Linux when paths unchanged (#144)
- ci.yml: add paths filters to push and pull_request triggers so the full
  Dagger check only runs when source-relevant files change (lib/, test/,
  android/, linux/, scripts/, ci/, Taskfile.yml, etc.).  Pure website,
  docs, and assets/changelog.txt commits no longer trigger ci.yml.

- deploy.yml: add check-changes job that diffs HEAD~1..HEAD and outputs
  android/linux booleans.  On workflow_dispatch both are always true.
  test-android-firebase, deploy-playstore, and deploy-apk are now
  conditional on android==true; build-linux is conditional on linux==true.
  label-deploy-health only fires when at least one build job actually ran
  (not all skipped) and treats 'skipped' as acceptable in ALL_SUCCEEDED.

- ci/main.go Graph(): update Mermaid diagram to reflect the new two-
  workflow structure (ci.yml fast-check + deploy.yml with change-gated jobs).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:24:50 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 7ece6f09e5 feat: make sharedinbox.de heading a link and add git commit row to about table (#199)
- Wrap the '## sharedinbox.de' heading in a markdown hyperlink to https://sharedinbox.de
- Add a dedicated 'Git Commit' table row with a clickable link to the commit on Codeberg when GIT_HASH is set
- Update clipboard test to assert the heading link is present in copied markdown

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:04:54 +02:00
11 changed files with 114 additions and 368 deletions
-3
View File
@@ -156,7 +156,6 @@ 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 }}
@@ -198,7 +197,6 @@ 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"
@@ -240,7 +238,6 @@ 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"
+6 -7
View File
@@ -202,8 +202,6 @@ 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: |
@@ -217,20 +215,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 "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
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"
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" \
EXISTING=$(ssh -o StrictHostKeyChecking=no "$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 "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
else
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
fi
- name: Generate build history pages
@@ -246,5 +244,6 @@ jobs:
rsync -avz --delete \
--exclude='*.apk' \
--exclude='*.tar.gz' \
-e "ssh -o StrictHostKeyChecking=no" \
website/public/ \
"$SSH_USER@$SSH_HOST:public_html/"
+16 -41
View File
@@ -215,10 +215,8 @@ 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 --known-hosts env:SSH_KNOWN_HOSTS --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 --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
@@ -253,24 +251,17 @@ 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 --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)"
- 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)"
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 env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST"
- 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"
check-dagger:
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
@@ -382,29 +373,25 @@ 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 "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
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"
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 "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
EXISTING=$(ssh -o StrictHostKeyChecking=no "$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 "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
else
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
fi
echo "Uploaded $TARBALL and updated latest.json"
@@ -429,28 +416,24 @@ 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 "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp /tmp/$ZIPFILE "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$ZIPFILE"
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"
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$ZIPFILE"
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
EXISTING=$(ssh -o StrictHostKeyChecking=no "$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 "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
else
echo "{\"version\":\"$HASH\",\"windows\":\"$DOWNLOAD_URL\"}" | \
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
fi
echo "Uploaded $ZIPFILE and updated latest.json"
@@ -600,18 +583,14 @@ 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 "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp \
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp -o StrictHostKeyChecking=no \
build/app/outputs/flutter-apk/app-release.apk \
"$SSH_USER@$SSH_HOST:$REMOTE_DIR/$APK_NAME"
echo "Uploaded $APK_NAME to $REMOTE_DIR"
@@ -640,16 +619,12 @@ 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/
+12 -18
View File
@@ -318,13 +318,12 @@ func (m *Ci) Hugo() *dagger.Container {
}
// Deploy container for rsync/ssh
func (m *Ci) Deployer(sshKey *dagger.Secret, knownHosts *dagger.Secret) *dagger.Container {
func (m *Ci) Deployer(sshKey *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}).
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
WithEnvVariable("RSYNC_RSH", "ssh -i /root/.ssh/id_ed25519")
WithEnvVariable("RSYNC_RSH", "ssh -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519")
}
// Stalwart mail server service for backend and integration tests.
@@ -515,7 +514,6 @@ 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 {
@@ -527,7 +525,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}).
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
WithExec([]string{"chmod", "700", "/root/.ssh"}).
WithEnvVariable("SSH_USER", sshUser).
WithEnvVariable("SSH_HOST", sshHost).
WithDirectory("/src", scriptSource).
@@ -540,11 +538,10 @@ 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, knownHosts, sshUser, sshHost)
buildHistory := m.GenerateBuildHistory(ctx, sshKey, sshUser, sshHost)
websiteSource := m.Source.Filter(dagger.DirectoryFilterOpts{
Include: []string{"website/"},
@@ -561,13 +558,12 @@ 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, knownHosts, sshUser, sshHost)
public := m.BuildWebsite(ctx, sshKey, sshUser, sshHost)
return m.Deployer(sshKey, knownHosts).
return m.Deployer(sshKey).
WithDirectory("/public", public).
WithExec([]string{"rsync", "-avz", "--delete",
"--exclude=*.apk", "--exclude=*.tar.gz",
@@ -593,7 +589,6 @@ 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,
@@ -604,11 +599,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, knownHosts).
return m.Deployer(sshKey).
WithDirectory("/bundle", bundle).
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("tar -czf /tmp/%s -C /bundle .", 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)}).
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)}).
Stdout(ctx)
}
@@ -631,7 +626,6 @@ 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,
@@ -645,10 +639,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, knownHosts).
return m.Deployer(sshKey).
WithFile("/tmp/app.apk", apk).
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)}).
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)}).
Stdout(ctx)
}
+4 -37
View File
@@ -37,9 +37,6 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
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() {
@@ -79,35 +76,8 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
setState(() {
_step = _Step.scanning;
_scannerActive = true;
_scannerController = MobileScannerController();
});
if (_cameraScanSupported()) {
unawaited(_initScanner());
}
}
// Pre-flight: start + stop the scanner to verify the plugin is available.
// Falls back to text entry on any exception (including MissingPluginException).
Future<void> _initScanner() async {
MobileScannerController? ctrl;
bool available = false;
try {
ctrl = MobileScannerController();
await ctrl.start();
await ctrl.stop();
available = true;
} catch (_) {
// Plugin not available on this device; text fallback will be shown.
} finally {
try {
await ctrl?.dispose();
} catch (_) {}
}
if (!mounted) return;
if (available) {
setState(() => _scannerController = MobileScannerController());
} else {
setState(() => _scannerFailed = true);
}
}
Future<void> _onScanned(String rawValue) async {
@@ -296,14 +266,11 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
}
Widget _buildScannerView(BuildContext context) {
// 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) {
// On platforms where the camera scanner is not available (Linux desktop),
// fall back to a text-input field.
if (!_cameraScanSupported()) {
return _buildTextFallbackView(context);
}
if (_scannerController == null) {
return const Center(child: CircularProgressIndicator());
}
return Stack(
children: [
+2 -33
View File
@@ -45,40 +45,12 @@ 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()) {
unawaited(_initScanner());
}
}
// Pre-flight: start + stop the scanner to verify the plugin is available.
// Falls back to text entry on any exception (including MissingPluginException).
Future<void> _initScanner() async {
MobileScannerController? ctrl;
bool available = false;
try {
ctrl = MobileScannerController();
await ctrl.start();
await ctrl.stop();
available = true;
} catch (_) {
// Plugin not available on this device; text fallback will be shown.
} finally {
try {
await ctrl?.dispose();
} catch (_) {}
}
if (!mounted) return;
if (available) {
setState(() => _scannerController = MobileScannerController());
} else {
setState(() => _scannerFailed = true);
_scannerController = MobileScannerController();
}
}
@@ -206,12 +178,9 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
}
Widget _buildScanStep(BuildContext context) {
if (!_cameraScanSupported() || _scannerFailed) {
if (!_cameraScanSupported()) {
return _buildTextFallbackView(context);
}
if (_scannerController == null) {
return const Center(child: CircularProgressIndicator());
}
return Stack(
children: [
+35 -24
View File
@@ -10,12 +10,12 @@ 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');
Future<String> _buildReport() async {
String version = 'unknown';
@@ -25,8 +25,8 @@ class CrashScreen extends StatelessWidget {
} catch (_) {}
final platform =
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
final gitLine = gitHash.isNotEmpty
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
final gitLine = _gitHash.isNotEmpty
? 'Git Commit: [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)\n'
: '';
return 'App Version: $version\n'
'$gitLine'
@@ -56,27 +56,12 @@ class CrashScreen extends StatelessWidget {
style: Theme.of(ctx).textTheme.titleMedium,
textAlign: TextAlign.center,
),
if (gitHash.isNotEmpty) ...[
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 Text(
'Git Commit: $_gitHash',
style: TextStyle(fontSize: 12, color: Colors.grey),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 24),
@@ -121,6 +106,32 @@ class CrashScreen extends StatelessWidget {
),
),
],
if (_gitHash.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
'Git Commit:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
GestureDetector(
onTap: () async {
final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/commit/$_gitHash',
);
await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
},
child: const Text(
_gitHash,
style: TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
),
),
],
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () async {
+19 -79
View File
@@ -8,15 +8,12 @@ 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,
save state, exit 0
g. No Ready issues → print "nothing to do", exit 0
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
Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
@@ -145,19 +142,10 @@ 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 and schedule runs).
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 push
events on the 'main' prettyref so section-3 logic only reacts to main.
"""
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
def _latest_ci_run() -> dict | None:
data = _tea_get(f"repos/{REPO}/actions/runs?limit=1")
runs = (data or {}).get("workflow_runs", [])
for run in runs:
if run.get("event") == "push" and run.get("prettyref") == "main":
return run
return None
return runs[0] if runs else None
def _latest_ci_run_for_branch(branch: str) -> dict | None:
@@ -177,7 +165,7 @@ def _latest_ci_run_for_branch(branch: str) -> dict | None:
return run
except (json.JSONDecodeError, AttributeError):
pass
elif run.get("event") == "push":
else:
if run.get("prettyref") == branch:
return run
return None
@@ -345,15 +333,6 @@ 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:
@@ -388,13 +367,11 @@ def _git_summary() -> str:
def _kill_agent(state: dict) -> None:
"""Forcefully stop the running agent."""
pid = state.get("pid")
if pid and _is_claude_process(pid):
if 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 ───────────────────────────────────────────────────────────────
@@ -573,25 +550,7 @@ 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}.")
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
_merge_pr(pr_number)
_close_issue(pending_issue)
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
return 0
@@ -668,8 +627,8 @@ def _run_loop() -> int:
print(f"Merged PR #{pr_number}.")
return 0
# ── 3. Global CI check (main branch only) ────────────────────────────────
run = _latest_main_ci_run()
# ── 3. Global CI check (agent pushed to main, or no pending issue) ────────
run = _latest_ci_run()
if run and run.get("status") == "running":
print(f"CI run {_ci_run_url(run['id'])} is still running. Waiting.")
@@ -678,33 +637,17 @@ 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 on the main branch. "
"The Codeberg CI for guettli/sharedinbox just failed. "
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 directly to main. "
"Identify the failure, fix it, commit, and push. "
"Verify locally with 'task check' before pushing. "
"Do NOT push to main. "
"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. "
"not an issue fix, and auto-closing the wrong issue would be a bug. "
"Do NOT close any issues. "
"When done, stop."
)
@@ -769,10 +712,7 @@ 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 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.
- Commit with a descriptive message referencing the issue number (e.g. "feat: ... (#{issue_number})").
- 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
+3
View File
@@ -33,6 +33,9 @@ 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",
],
+17 -82
View File
@@ -88,47 +88,21 @@ 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._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)
with patch("agent_loop.os.kill") as mock_kill:
agent_loop._kill_agent({"pid": 1234})
mock_kill.assert_called_once_with(1234, 9)
def test_kill_ignores_missing_process(self):
with patch("agent_loop._is_claude_process", return_value=True):
with patch("agent_loop.os.kill", side_effect=ProcessLookupError):
agent_loop._kill_agent({"pid": 1234}) # Should not raise.
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):
@@ -200,8 +174,7 @@ class TestMain(unittest.TestCase):
return 55
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._latest_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), \
@@ -227,8 +200,7 @@ class TestMain(unittest.TestCase):
captured["remove"] = remove
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._latest_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), \
@@ -241,8 +213,7 @@ 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._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._latest_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:
@@ -261,8 +232,7 @@ class TestMain(unittest.TestCase):
return 77
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._latest_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), \
@@ -296,9 +266,8 @@ 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._open_pr(), None]), \
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._merge_pr") as mock_merge, \
patch("agent_loop._close_issue") as mock_close, \
@@ -313,7 +282,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._open_pr(), None]), \
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 4145144, "status": "success"}), \
patch("agent_loop._merge_pr"), \
patch("agent_loop._close_issue"), \
@@ -423,7 +392,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._open_pr(), None]), \
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._merge_pr") as mock_merge, \
patch("agent_loop._close_issue") as mock_close, \
@@ -440,8 +409,7 @@ class TestPendingCi(unittest.TestCase):
"pid": 999999999, "issue": None, "started_at": "2026-01-01T00:00:00+00:00",
"type": "ci-fix",
}), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._latest_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"):
@@ -457,8 +425,7 @@ 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._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._latest_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
@@ -469,8 +436,7 @@ 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._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._latest_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
@@ -480,8 +446,7 @@ class TestOutputFormat(unittest.TestCase):
run = {"id": 4145144, "status": "running"}
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=run), \
patch("agent_loop._latest_ci_run", return_value=run), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144",
@@ -491,8 +456,7 @@ 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._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._latest_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), \
@@ -504,35 +468,6 @@ class TestOutputFormat(unittest.TestCase):
self.assertIn("Fix something", output)
class TestLatestMainCiRun(unittest.TestCase):
"""_latest_main_ci_run() must return only push-to-main runs, ignoring schedule/deploy workflows."""
def test_skips_schedule_runs_returns_push_to_main(self):
runs = [
{"event": "schedule", "prettyref": "main", "status": "success", "id": 1},
{"event": "push", "prettyref": "main", "status": "success", "id": 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_schedule_runs_exist(self):
runs = [
{"event": "schedule", "prettyref": "main", "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_push_to_main_run(self):
runs = [{"event": "push", "prettyref": "main", "status": "running", "id": 42}]
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."""
-44
View File
@@ -123,50 +123,6 @@ void main() {
},
);
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 used as root widget — buttons work without ScaffoldMessenger crash',
(tester) async {