Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 ba1f324831 fix(agent-loop): replace tea with fgj for CI run queries
tea api returns HTML 504 pages with exit 0, causing JSONDecodeError.
fgj actions run list is more reliable and consistent with the rest of
the script. Adapted PR-event matching to use prettyref="#N" since fgj
does not expose event_payload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:24:17 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 529fb56cf8 fix: add explicit note that app settings are never uploaded (#280)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:22:53 +02:00
2 changed files with 50 additions and 43 deletions
+46 -40
View File
@@ -46,7 +46,7 @@ import time
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
# Cron runs with a minimal PATH; ensure Nix profile binaries (tea, claude) and ~/go/bin (fgj) are found. # Cron runs with a minimal PATH; ensure Nix profile binaries (claude) and ~/go/bin (fgj) are found.
os.environ["PATH"] = ( os.environ["PATH"] = (
f"{Path.home()}/.nix-profile/bin" f"{Path.home()}/.nix-profile/bin"
f":{Path.home()}/go/bin" f":{Path.home()}/go/bin"
@@ -97,27 +97,27 @@ def _fgj(*args: str) -> None:
) )
def _tea_get(path: str) -> dict | list | None: def _fgj_run_list(limit: int = 20) -> list[dict]:
"""Run a tea api GET and return parsed JSON. Only use for reads — tea PATCH/PUT """Return workflow runs via fgj actions run list."""
silently fails (exits 0) when unauthenticated, so writes must go via fgj.""" result = subprocess.run(
cmd = ["tea", "api", path] ["fgj", "--hostname", "codeberg.org", "actions", "run", "list",
result = subprocess.run(cmd, capture_output=True, text=True) "--repo", REPO, "--json", "-L", str(limit)],
capture_output=True, text=True,
)
if result.returncode != 0: if result.returncode != 0:
raise RuntimeError( raise RuntimeError(
f"tea api {path} failed:\n{result.stderr or result.stdout}" f"fgj actions run list failed:\n{result.stderr or result.stdout}"
) )
out = result.stdout.strip() out = result.stdout.strip()
if not out: if not out:
return None return []
try: try:
data = json.loads(out) data = json.loads(out)
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
raise RuntimeError( raise RuntimeError(
f"tea api {path} returned non-JSON (exit 0):\n{out[:500]}" f"fgj actions run list returned non-JSON:\n{out[:500]}"
) from exc ) from exc
if isinstance(data, dict) and "message" in data and "url" in data: return data if isinstance(data, list) else []
raise RuntimeError(f"tea api {path} returned error: {data['message']}")
return data
def _set_labels(issue: int, add: list[str], remove: list[str]) -> None: def _set_labels(issue: int, add: list[str], remove: list[str]) -> None:
@@ -186,9 +186,7 @@ def _latest_main_ci_run() -> dict | None:
event=push and prettyref=main, so filtering by event alone is not enough. event=push and prettyref=main, so filtering by event alone is not enough.
We also require workflow_id == "ci.yml". We also require workflow_id == "ci.yml".
""" """
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20") for run in _fgj_run_list(limit=20):
runs = (data or {}).get("workflow_runs", [])
for run in runs:
if (run.get("event") == "push" if (run.get("event") == "push"
and run.get("prettyref") == "main" and run.get("prettyref") == "main"
and run.get("workflow_id") == "ci.yml"): and run.get("workflow_id") == "ci.yml"):
@@ -199,20 +197,16 @@ def _latest_main_ci_run() -> dict | None:
def _latest_ci_run_for_branch(branch: str) -> dict | None: def _latest_ci_run_for_branch(branch: str) -> dict | None:
"""Return the latest CI run for a specific branch, or None. """Return the latest CI run for a specific branch, or None.
Forgejo's workflow_runs API has no top-level head_branch field. For push events fgj reports the branch in ``prettyref``; for pull_request
For push events the branch is in ``prettyref``; for pull_request events ``prettyref`` is ``#N``, so we resolve the PR number first.
events it lives inside ``event_payload["pull_request"]["head"]["ref"]``.
""" """
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20") runs = _fgj_run_list(limit=20)
runs = (data or {}).get("workflow_runs", []) pr_data = _find_pr_for_branch(branch)
pr_ref = f"#{pr_data['number']}" if pr_data else None
for run in runs: for run in runs:
if run.get("event") == "pull_request": if run.get("event") == "pull_request":
try: if pr_ref and run.get("prettyref") == pr_ref:
payload = json.loads(run.get("event_payload", "{}"))
if payload.get("pull_request", {}).get("head", {}).get("ref") == branch:
return run return run
except (json.JSONDecodeError, AttributeError):
pass
elif run.get("event") == "push": elif run.get("event") == "push":
if run.get("prettyref") == branch: if run.get("prettyref") == branch:
return run return run
@@ -259,24 +253,27 @@ def _open_issue_prs() -> list[dict]:
def _latest_ci_run_for_pr(pr_number: int) -> dict | None: def _latest_ci_run_for_pr(pr_number: int) -> dict | None:
"""Return the latest CI run triggered by a pull_request event for the given PR number.""" """Return the latest CI run triggered by a pull_request event for the given PR number."""
data = _tea_get(f"repos/{REPO}/actions/runs?event=pull_request&limit=50") pr_ref = f"#{pr_number}"
runs = (data or {}).get("workflow_runs", []) for run in _fgj_run_list(limit=50):
for run in runs: if run.get("event") == "pull_request" and run.get("prettyref") == pr_ref:
try:
payload = json.loads(run.get("event_payload", "{}"))
if payload.get("pull_request", {}).get("number") == pr_number:
return run return run
except (json.JSONDecodeError, AttributeError):
pass
return None return None
def _get_issue_labels(issue: int) -> list[str]: def _get_issue_labels(issue: int) -> list[str]:
"""Return label names for an issue.""" """Return label names for an issue."""
data = _tea_get(f"repos/{REPO}/issues/{issue}") result = subprocess.run(
if not data: ["fgj", "--hostname", "codeberg.org", "issue", "view", str(issue),
"--repo", REPO, "--json"],
capture_output=True, text=True,
)
if result.returncode != 0 or not result.stdout.strip():
return [] return []
return [lbl["name"] for lbl in data.get("labels", [])] try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
return []
return [lbl["name"] for lbl in data.get("issue", {}).get("labels", [])]
def _merge_pr(pr_number: int) -> None: def _merge_pr(pr_number: int) -> None:
@@ -292,8 +289,18 @@ def _handle_pr_still_open_after_merge(pr_number: int, branch: str, issue_num: in
"merged" — PR closed after a retry "merged" — PR closed after a retry
"fallback" — all options exhausted; caller should set State/Question "fallback" — all options exhausted; caller should set State/Question
""" """
pr_data = _tea_get(f"repos/{REPO}/pulls/{pr_number}") result = subprocess.run(
mergeable = (pr_data or {}).get("mergeable") ["fgj", "--hostname", "codeberg.org", "pr", "view", str(pr_number),
"--repo", REPO, "--json"],
capture_output=True, text=True,
)
pr_data: dict = {}
if result.returncode == 0 and result.stdout.strip():
try:
pr_data = json.loads(result.stdout)
except json.JSONDecodeError:
pass
mergeable = pr_data.get("mergeable")
if mergeable is False: if mergeable is False:
prompt = ( prompt = (
@@ -836,9 +843,8 @@ def _run_loop() -> int:
# spawning another agent, check whether any CI run is currently in # spawning another agent, check whether any CI run is currently in
# progress (the branch run) and wait if so. # 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: 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 = [ in_flight = [
r for r in (check or {}).get("workflow_runs", []) r for r in _fgj_run_list(limit=5)
if r.get("status") == "running" if r.get("status") == "running"
] ]
if in_flight: if in_flight:
+2 -1
View File
@@ -25,7 +25,8 @@ The app processes the following data **exclusively on your device**:
device's secure storage and never transmitted to us. device's secure storage and never transmitted to us.
- **Email messages and attachments** — fetched directly from your email provider's IMAP server and - **Email messages and attachments** — fetched directly from your email provider's IMAP server and
displayed in the app. We never receive, store, or process your emails. displayed in the app. We never receive, store, or process your emails.
- **App settings and configuration** — stored locally on your device. - **App settings and configuration** — stored locally on your device. The app will never upload
this data to sharedinbox.de or any third-party service.
### Network connections ### Network connections