Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 b934894505 fix: pin SSH host key via known_hosts instead of StrictHostKeyChecking=no (#161)
- Add knownHosts *dagger.Secret parameter to Deployer(), DeployLinux(),
  DeployApk(), PublishWebsite(), BuildWebsite(), GenerateBuildHistory()
- Mount SSH_KNOWN_HOSTS secret at /root/.ssh/known_hosts (mode 0644) in
  both the Deployer container and the GenerateBuildHistory container
- Remove all -o StrictHostKeyChecking=no flags from ssh/scp/rsync calls
  in ci/main.go and scripts/generate_build_history.py
- Update Taskfile dagger tasks (deploy-linux, deploy-apk, publish-website)
  to require SSH_KNOWN_HOSTS and pass --known-hosts env:SSH_KNOWN_HOSTS
- Fix non-Dagger Taskfile tasks to write SSH_KNOWN_HOSTS to ~/.ssh/known_hosts
- Fix .forgejo/workflows/deploy.yml to pass SSH_KNOWN_HOSTS secret
- Fix .github/workflows/ci.yml SSH setup step to write known_hosts file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 12:51:33 +02:00
6 changed files with 77 additions and 327 deletions
+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 -107
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 ───────────────────────────────────────────────────────────────
@@ -532,9 +509,6 @@ 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."
)
@@ -573,25 +547,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
@@ -641,26 +597,7 @@ 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.")
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
_merge_pr(pr_number)
if issue_num:
_close_issue(issue_num)
print(f"Merged PR #{pr_number} and closed issue #{issue_num}.")
@@ -668,8 +605,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,39 +615,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 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",
ci_run_id=run["id"] if run else None)
_write_state(pid, pending_issue, "ci-fix", session_name="ci-fix")
return 0
# CI is ok (or no run).
@@ -769,10 +684,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
+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 {