diff --git a/.aipass/aipass_global_prompt.md b/.aipass/aipass_global_prompt.md index 372e663d..5f8c3abb 100644 --- a/.aipass/aipass_global_prompt.md +++ b/.aipass/aipass_global_prompt.md @@ -159,7 +159,7 @@ Your `.trinity/` files are your persistence. Without them you're just an instanc - `local.json` — session history, active tasks, learnings - `observations.json` — collaboration patterns over time -Update `.trinity/` at natural breakpoints, after milestones, and on `/memo`. If compaction hits before you save, it's gone. Details in your branch prompt. +Update `.trinity/` at natural breakpoints, after milestones, and on `/memo`. Details in your branch prompt. ### STATUS.local.md — Equal Priority @@ -171,7 +171,6 @@ Save memories **proactively**. Don't wait for `/memo` or end of session. These a - **After a milestone** — task completed, bug fixed, dispatch cycle done, plan closed - **After a decision** — the user chose an approach, rejected an idea, taught you something - **After learning something new** — a pattern, a gotcha, a command quirk, a system behavior -- **Every ~10 turns** — if you haven't saved recently, save now. Context can compact at any time - **Before switching topics** — capture what you learned before the conversation moves on - **When the user teaches** — if they correct you or share insight, that's a key_learning immediately @@ -180,7 +179,7 @@ What to save where: - `observations.json` → collaboration patterns (how the user works, what works well, what to avoid) - `STATUS.local.md` → current work, known issues, todos, recently completed. Surfaces in pre-compact recovery and startup reads. -**The cost of saving too often is zero. The cost of losing context to compaction is everything.** +**Don't stress about compaction.** We run on a 1M context window. Patrick monitors context usage and controls compaction manually — it's his job, not yours. Auto-compact is effectively obsolete. Save your memories because they're valuable, not because you're racing a clock. The cost of saving too often is zero. ## Breadcrumbs diff --git a/STATUS.md b/STATUS.md index f2999168..47449d20 100644 --- a/STATUS.md +++ b/STATUS.md @@ -2,7 +2,7 @@ > Auto-generated by `drone @prax status sync`. Do not edit manually. -**Last sync:** 2026-03-29 22:27 +**Last sync:** 2026-03-30 22:28 **Summary:** 9 operational | 0 in-progress | 0 not started --- @@ -331,37 +331,22 @@ Removed `_module` suffix from all 21 module files per seedgo naming standard ("P ## Notepad > **SCRATCH SPACE — gets wiped at session start or topic change.** -Session 63: -- Massive 6-branch audit session. 8-agent swarm per branch. -- DPLAN-0053 (drone): dispatched with 4 work items. Drone created FPLAN-0142 (master). Answered design questions. Drone building. -- DPLAN-0084 (seedgo): created + investigated. 31/32 checkers untested. Dispatched seedgo — COMPLETED all 4 phases (225→314 tests). Bug fixes, README updated, checker count corrected to 32. -- DPLAN-0074 (cli): investigated. No standalone aipass init. 116 tests clean. Dispatched. -- DPLAN-0039 (prax): investigated. 71 bypasses, 36/134 tested. JSON corruption cross-branch. Dispatched. -- DPLAN-0082 (flow): investigated. 180 tests, close orphan mitigated. Dispatched. -- DPLAN-0036 (ai_mail): investigated. 26/101 tested, 8x duplicated utility. Dispatched. -- Standards count: 32 not 33 (corrected in DPLAN-0084, needs fixing in STATUS/HERALD/README). -- Patrick's notepad reviewed (/home/patrick/Projects/AIPass/notepad.md) — noted per-branch concerns. -- Cross-branch pattern: handler tests are the gap everywhere, JSON corruption affects 4+ branches. -- 6 branches dispatched and working: drone, seedgo (done), prax, flow, ai_mail, cli. -- Still need: memory, devpulse, trigger (one-on-one), daemon (one-on-one), skills (one-on-one). -- 4 uncommitted files from S62 still pending. -- Round 2 dispatches: skills (list bug fix + demo skills), trigger (medic wiring), daemon (demo schedules), memory (venv fix), commons (social testing). -- Verification: 3343 tests pass system-wide, 43 skip, 0 fail. -- Seedgo broken test: test_cli_routing_template.py imports nonexistent module. Emailed seedgo. -- Backup performance: snapshot dry-run 21s, drive-stats 8s. Patrick says should be ms. Added to DPLAN-0034. -- Skills list bug: drone @skills list crashes — skills.py shadows package. Fix known (sys.path cleanup from commons). -- Commons tested: post/feed/thread all working. Posted S63 update. -- 25 open plans in flow — 7 old seedgo DPLANs may be closeable. -- Drone FPLAN-0142 COMPLETE — generic adapter built, 3 drone_adapter.py files archived, CWD scoping added. 393 tests. -- Skills COMPLETE — list bug fixed, 2 demo skills created (inbox_check, branch_health). 5 skills in catalog. -- Daemon COMPLETE — 3 demo schedules created, pipeline bug fixed, plugins verified, skipped test fixed. 236 tests. -- Memory COMPLETE — venv works, content placement by design, search verified. -- Commons COMPLETE — 12 social features E2E tested, coverage 67%→92% (328 tests), boardroom docs added. -- Trigger COMPLETE — Medic wired up with systemd service, E2E dispatch tested, 12 events documented. Bug fix: CLI fire parser type coercion. -- CLI broken test: test_integration.py imports archived drone_adapter. Emailed CLI. -- Seedgo broken template: test_cli_routing_template.py imports nonexistent module. Emailed seedgo. -- System tests: 3,330 pass, 42 skip (excluding 2 known broken files). -- inotify limit hit during heavy dispatch (too many simultaneous file watchers). +Session 65 (Night Shift): +- FPLAN-0154 COMPLETE: 14 branches dispatched across 3 phases, all replied successfully +- Phase 1 (critical): backup dry-run, ai_mail per-ID, flow introspection gate, drone atomic writes, commons 3-strategy detection +- Phase 2 (patterns): spawn argparse+dry-run, skills create help, api introspection routing, trigger empty JSON, seedgo @ prefix +- Phase 3 (polish): prax ghost module archived, daemon update+error cascade, memory search timeout, cli standalone verified +- Phase 4 (verification): 3,410 tests (+80), seedgo 99% avg, 0 failures +- Key pattern: introspection fallback (no-args gate before routing) was root cause across flow, api, daemon, prax +- Prax conftest.py updated: collect_ignore_glob for .archive/ +Session 65 (continued — master key): +- DPLAN-0087: devpulse_ops plugin suite complete. 4 plugins: system-pr, merge, smart-sync, fix +- Full test suite: 8/8 scenarios passed (PRs #147-153, all merged, repo clean) +- system-pr + merge cycle is the new standard workflow for system-wide PRs +- Phase 3 (snapshot_handler) designed in DPLAN but not built — handler not plugin +- FPLAN-0154 still open (can close now — all phases done) +- PR #146 (HERALD) merged during night shift sequence +- Next: update prompts/docs with new commands, build snapshot_handler, Patrick author mode diff --git a/src/aipass/drone/.seedgo/bypass.json b/src/aipass/drone/.seedgo/bypass.json index db20ff6e..82099784 100644 --- a/src/aipass/drone/.seedgo/bypass.json +++ b/src/aipass/drone/.seedgo/bypass.json @@ -73,9 +73,10 @@ "reason": "Plugin imports lock_handler directly — it's a drone-internal plugin in the same branch, not a cross-branch import." }, { - "file": "apps/plugins/devpulse_ops/pr_plugin.py", - "standard": "trigger", - "reason": "create_system_pr is a privileged operation — trigger integration deferred until trigger branch supports plugin events." + "file": "apps/handlers/git/pr_handler.py", + "standard": "handlers", + "lines": [210], + "reason": "Lazy import of trigger.fire() — event notification after PR creation. Non-blocking, wrapped in try/except." }, { "file": "apps/plugins/devpulse_ops/merge_plugin.py", diff --git a/src/aipass/drone/apps/handlers/git/pr_handler.py b/src/aipass/drone/apps/handlers/git/pr_handler.py index f4f7d5fd..219957ac 100644 --- a/src/aipass/drone/apps/handlers/git/pr_handler.py +++ b/src/aipass/drone/apps/handlers/git/pr_handler.py @@ -204,6 +204,14 @@ def create_pr(branch_name: str, description: str, branch_dir: Path) -> dict: {"branch": branch_name, "feature_branch": feature_branch, "pr_url": pr_url}, ) logger.info(result["message"]) + + # Fire pr_created event (non-blocking — never fail the PR workflow) + try: + from aipass.trigger.apps.modules.core import trigger + trigger.fire("pr_created", branch=branch_name, pr_url=pr_url) + except Exception as exc: + logger.warning("trigger.fire('pr_created') failed: %s", exc) + return result except (OSError, subprocess.SubprocessError) as exc: diff --git a/src/aipass/drone/apps/plugins/devpulse_ops/merge_plugin.py b/src/aipass/drone/apps/plugins/devpulse_ops/merge_plugin.py index 3af4e8fe..a336744d 100644 --- a/src/aipass/drone/apps/plugins/devpulse_ops/merge_plugin.py +++ b/src/aipass/drone/apps/plugins/devpulse_ops/merge_plugin.py @@ -91,6 +91,14 @@ def merge_pr(pr_number: str, caller: str) -> dict: }, ) logger.info(result["message"]) + + # Fire pr_merged event (non-blocking — never fail the merge workflow) + try: + from aipass.trigger.apps.modules.core import trigger + trigger.fire("pr_merged", pr_number=pr_number, title=title) + except Exception as exc: + logger.warning("trigger.fire('pr_merged') failed: %s", exc) + return result except (OSError, subprocess.SubprocessError) as exc: diff --git a/src/aipass/drone/apps/plugins/devpulse_ops/pr_plugin.py b/src/aipass/drone/apps/plugins/devpulse_ops/pr_plugin.py index e117b35c..81fa3b07 100644 --- a/src/aipass/drone/apps/plugins/devpulse_ops/pr_plugin.py +++ b/src/aipass/drone/apps/plugins/devpulse_ops/pr_plugin.py @@ -193,6 +193,14 @@ def create_system_pr(description: str, caller: str) -> dict: }, ) logger.info(result["message"]) + + # Fire pr_created event (non-blocking — never fail the PR workflow) + try: + from aipass.trigger.apps.modules.core import trigger + trigger.fire("pr_created", branch=caller, pr_url=pr_url) + except Exception as exc: + logger.warning("trigger.fire('pr_created') failed: %s", exc) + return result except (OSError, subprocess.SubprocessError) as exc: diff --git a/src/aipass/drone/tests/test_git_module.py b/src/aipass/drone/tests/test_git_module.py index 2da4be50..44960f5f 100644 --- a/src/aipass/drone/tests/test_git_module.py +++ b/src/aipass/drone/tests/test_git_module.py @@ -681,3 +681,137 @@ class TestModuleRegistration: assert hasattr(mod, "handle_command") assert hasattr(mod, "get_help") assert hasattr(mod, "get_introspective") + + +# =========================================================================== +# 7. trigger.fire() integration tests +# =========================================================================== + + +class TestTriggerFireIntegration: + """Verify trigger.fire() is called after successful PR/merge operations.""" + + def test_pr_handler_fires_pr_created(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """pr_handler.create_pr fires pr_created event on success.""" + registry = tmp_path / "AIPASS_REGISTRY.json" + registry.write_text("{}", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + call_count = 0 + + def mock_run(cmd, **kwargs): + nonlocal call_count + call_count += 1 + r = MagicMock() + r.stderr = "" + if cmd[1:3] == ["rev-parse", "--abbrev-ref"]: + r.returncode = 0 + r.stdout = "main\n" + elif cmd[0] == "git" and cmd[1] == "add": + r.returncode = 0 + r.stdout = "" + elif cmd[1:3] == ["diff", "--cached"]: + r.returncode = 1 # 1 = something staged + r.stdout = "" + elif cmd[0] == "git" and cmd[1] == "commit": + r.returncode = 0 + r.stdout = "" + elif cmd[0] == "git" and cmd[1] == "branch": + r.returncode = 0 + r.stdout = "" + elif cmd[0] == "git" and cmd[1] == "push": + r.returncode = 0 + r.stdout = "" + elif cmd[0] == "gh" and cmd[1] == "pr": + r.returncode = 0 + r.stdout = "https://github.com/org/repo/pull/99\n" + else: + r.returncode = 0 + r.stdout = "" + return r + + mock_trigger = MagicMock() + + with patch("aipass.drone.apps.handlers.git.pr_handler.subprocess.run", side_effect=mock_run): + with patch("aipass.drone.apps.handlers.git.pr_handler.acquire_lock", return_value={"success": True, "message": "ok"}): + with patch("aipass.drone.apps.handlers.git.pr_handler.release_lock"): + with patch("aipass.trigger.apps.modules.core.trigger", mock_trigger): + result = create_pr("api", "test trigger", tmp_path / "src" / "aipass" / "api") + + assert result["success"] is True + mock_trigger.fire.assert_any_call("pr_created", branch="api", pr_url="https://github.com/org/repo/pull/99") + + def test_pr_handler_continues_if_trigger_fails(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """pr_handler.create_pr succeeds even if trigger.fire raises.""" + registry = tmp_path / "AIPASS_REGISTRY.json" + registry.write_text("{}", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + def mock_run(cmd, **kwargs): + r = MagicMock() + r.stderr = "" + if cmd[1:3] == ["rev-parse", "--abbrev-ref"]: + r.returncode = 0 + r.stdout = "main\n" + elif cmd[1:3] == ["diff", "--cached"]: + r.returncode = 1 + r.stdout = "" + elif cmd[0] == "gh" and cmd[1] == "pr": + r.returncode = 0 + r.stdout = "https://github.com/org/repo/pull/100\n" + else: + r.returncode = 0 + r.stdout = "" + return r + + mock_trigger = MagicMock() + mock_trigger.fire.side_effect = RuntimeError("trigger broken") + + with patch("aipass.drone.apps.handlers.git.pr_handler.subprocess.run", side_effect=mock_run): + with patch("aipass.drone.apps.handlers.git.pr_handler.acquire_lock", return_value={"success": True, "message": "ok"}): + with patch("aipass.drone.apps.handlers.git.pr_handler.release_lock"): + with patch("aipass.trigger.apps.modules.core.trigger", mock_trigger): + result = create_pr("api", "test resilience", tmp_path / "src" / "aipass" / "api") + + assert result["success"] is True # PR still succeeds despite trigger failure + + def test_merge_plugin_fires_pr_merged(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """merge_plugin.merge_pr fires pr_merged event on success.""" + registry = tmp_path / "AIPASS_REGISTRY.json" + registry.write_text("{}", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + from aipass.drone.apps.plugins.devpulse_ops.merge_plugin import merge_pr + + call_idx = 0 + + def mock_run(cmd, **kwargs): + nonlocal call_idx + call_idx += 1 + r = MagicMock() + r.stderr = "" + if cmd[0] == "gh" and cmd[1] == "pr" and cmd[2] == "merge": + r.returncode = 0 + r.stdout = "" + elif cmd[0] == "git" and cmd[1] == "pull": + r.returncode = 0 + r.stdout = "" + elif cmd[1:3] == ["rev-parse", "HEAD"]: + r.returncode = 0 + r.stdout = "abc123def456\n" + elif cmd[0] == "gh" and cmd[1] == "pr" and cmd[2] == "view": + r.returncode = 0 + r.stdout = "Fix the thing\n" + else: + r.returncode = 0 + r.stdout = "" + return r + + mock_trigger = MagicMock() + + with patch("aipass.drone.apps.plugins.devpulse_ops.merge_plugin.subprocess.run", side_effect=mock_run): + with patch("aipass.trigger.apps.modules.core.trigger", mock_trigger): + result = merge_pr("42", "devpulse") + + assert result["success"] is True + mock_trigger.fire.assert_any_call("pr_merged", pr_number="42", title="Fix the thing") diff --git a/src/aipass/flow/apps/handlers/dashboard/push_branch_dashboard.py b/src/aipass/flow/apps/handlers/dashboard/push_branch_dashboard.py index 1944c8db..65e6ae18 100644 --- a/src/aipass/flow/apps/handlers/dashboard/push_branch_dashboard.py +++ b/src/aipass/flow/apps/handlers/dashboard/push_branch_dashboard.py @@ -166,7 +166,9 @@ def _calculate_quick_status(sections: Dict[str, Any]) -> Dict[str, Any]: new_mail = ai_mail.get("new", ai_mail.get("unread", 0)) opened_mail = ai_mail.get("opened", 0) - active_plans = flow.get("active_plans", 0) + active_plans = flow.get("active_count", 0) + if isinstance(active_plans, list): + active_plans = len(active_plans) mentions = commons.get("mentions", 0) action_required = new_mail > 0 or active_plans > 0 or mentions > 0 diff --git a/src/aipass/trigger/.seedgo/bypass.json b/src/aipass/trigger/.seedgo/bypass.json index 3a2da971..1e350e05 100644 --- a/src/aipass/trigger/.seedgo/bypass.json +++ b/src/aipass/trigger/.seedgo/bypass.json @@ -343,6 +343,24 @@ "standard": "imports", "pattern": "prax logger import", "reason": "Systemd service \u2014 uses print() to journald, not prax logger. Importing prax adds unnecessary dependency for a standalone daemon." + }, + { + "file": "apps/handlers/events/pr_status_sync.py", + "standard": "silent_catch", + "pattern": "_log_info except", + "reason": "Meta-logging helper: _log_info() writes directly to file. Its own except block cannot log \u2014 you cannot log a failure to log." + }, + { + "file": "apps/handlers/events/pr_status_sync.py", + "standard": "error_handling", + "pattern": "except Exception: pass", + "reason": "Meta-logging helper _log_info() \u2014 cannot log a failure to log. Same pattern as silent_catch bypass." + }, + { + "file": "apps/handlers/events/pr_status_sync.py", + "standard": "trigger", + "pattern": "handle_pr", + "reason": "PR event handlers (handle_pr_created, handle_pr_merged) are event handlers registered in registry.py, not trigger-specific naming violations." } ], "notes": { diff --git a/src/aipass/trigger/README.md b/src/aipass/trigger/README.md index 82eaa0dd..c975121f 100644 --- a/src/aipass/trigger/README.md +++ b/src/aipass/trigger/README.md @@ -2,7 +2,7 @@ **Purpose:** Event bus for AIPass. Branches fire events, registered handlers react. Decouples producers from consumers — the module that detects a condition doesn't need to know what should happen next. **Module:** `aipass.trigger` -**Last Updated:** 2026-03-29 +**Last Updated:** 2026-03-30 ## Commands / Usage @@ -64,7 +64,7 @@ result = report_error( ## Events -12 events registered via `handlers/events/registry.py`. All fire through `Trigger.fire()`. +14 events registered via `handlers/events/registry.py`. All fire through `Trigger.fire()`. | Event | Handler | Fired when | Action | |-------|---------|------------|--------| @@ -80,6 +80,8 @@ result = report_error( | `memory_template_updated` | `memory_template_updated.py` | Memory template modified | Pushes template updates to branches | | `memory_saved` | `memory.py` | Memory file saved | Placeholder for future rollover trigger | | `cli_header_displayed` | `cli.py` | CLI displays headers | Registration hook | +| `pr_created` | `pr_status_sync.py` | PR opened on GitHub | Runs `drone @prax status sync` (fire-and-forget) | +| `pr_merged` | `pr_status_sync.py` | PR merged on GitHub | Runs `drone @prax status sync` (fire-and-forget) | ## Medic @@ -125,4 +127,4 @@ trigger/ --- -*Last Updated: 2026-03-29* +*Last Updated: 2026-03-30* diff --git a/src/aipass/trigger/apps/handlers/events/pr_status_sync.py b/src/aipass/trigger/apps/handlers/events/pr_status_sync.py new file mode 100644 index 00000000..cb9b7a57 --- /dev/null +++ b/src/aipass/trigger/apps/handlers/events/pr_status_sync.py @@ -0,0 +1,91 @@ +# =================== AIPass ==================== +# Name: pr_status_sync.py +# Description: PR event handlers — sync STATUS.md on PR create/merge +# Version: 1.0.0 +# Created: 2026-03-30 +# Modified: 2026-03-30 +# ============================================= + +""" +PR Status Sync Event Handlers + +Handles pr_created and pr_merged events by running +'drone @prax status sync' in a fire-and-forget subprocess. + +Events: + pr_created — fired when a PR is opened + data: {branch: str, pr_url: str} + pr_merged — fired when a PR is merged + data: {pr_number: str, title: str} +""" + +import subprocess +from datetime import datetime, timezone +from typing import Any + +from aipass.trigger.apps.config import TRIGGER_ROOT +from aipass.trigger.apps.handlers.json import json_handler + +_HANDLER_LOG = TRIGGER_ROOT / "logs" / "pr_status_sync_handler.log" + + +def _log_info(message: str) -> None: + """Log to file (event handlers cannot import prax logger — causes recursion).""" + try: + _HANDLER_LOG.parent.mkdir(parents=True, exist_ok=True) + ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + with open(_HANDLER_LOG, 'a', encoding='utf-8') as f: + f.write(f"{ts} | INFO | {message}\n") + except Exception: + pass # Meta-logging: cannot log a failure to log + + +def _run_status_sync(reason: str) -> None: + """Fire-and-forget: run drone @prax status sync.""" + try: + subprocess.Popen( + ["drone", "@prax", "status", "sync"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + _log_info(f"status sync launched ({reason})") + except Exception as exc: + _log_info(f"status sync failed ({reason}): {exc}") + + +def handle_pr_created( + branch: str | None = None, + pr_url: str | None = None, + **kwargs: Any, +) -> None: + """Handle pr_created event — trigger STATUS.md sync. + + Args: + branch: Branch that created the PR + pr_url: URL of the created PR + **kwargs: Additional event data (ignored) + """ + _run_status_sync(f"pr_created by {branch or 'unknown'}") + json_handler.log_operation("pr_created_event", { + "branch": branch or "unknown", + "pr_url": pr_url or "", + }) + + +def handle_pr_merged( + pr_number: str | None = None, + title: str | None = None, + **kwargs: Any, +) -> None: + """Handle pr_merged event — trigger STATUS.md sync. + + Args: + pr_number: PR number that was merged + title: PR title + **kwargs: Additional event data (ignored) + """ + _run_status_sync(f"pr_merged #{pr_number or '?'}") + json_handler.log_operation("pr_merged_event", { + "pr_number": pr_number or "", + "title": title or "", + }) diff --git a/src/aipass/trigger/apps/handlers/events/registry.py b/src/aipass/trigger/apps/handlers/events/registry.py index 94514288..a0c4a5db 100644 --- a/src/aipass/trigger/apps/handlers/events/registry.py +++ b/src/aipass/trigger/apps/handlers/events/registry.py @@ -70,6 +70,7 @@ def setup_handlers(): from .bulletin_created import handle_bulletin_created from .memory_threshold_exceeded import handle_memory_threshold_exceeded from .memory_template_updated import handle_memory_template_updated + from .pr_status_sync import handle_pr_created, handle_pr_merged trigger.on('startup', handle_startup) trigger.on('memory_saved', handle_memory_saved) @@ -83,5 +84,7 @@ def setup_handlers(): trigger.on('bulletin_created', handle_bulletin_created) trigger.on('memory_threshold_exceeded', handle_memory_threshold_exceeded) trigger.on('memory_template_updated', handle_memory_template_updated) + trigger.on('pr_created', handle_pr_created) + trigger.on('pr_merged', handle_pr_merged) json_handler.log_operation("handlers_registered", {"success": True}) diff --git a/src/aipass/trigger/tests/test_pr_status_sync.py b/src/aipass/trigger/tests/test_pr_status_sync.py new file mode 100644 index 00000000..06a07e52 --- /dev/null +++ b/src/aipass/trigger/tests/test_pr_status_sync.py @@ -0,0 +1,129 @@ +# =================== AIPass ==================== +# Name: test_pr_status_sync.py +# Description: Tests for pr_created and pr_merged event handlers +# Version: 1.0.0 +# Created: 2026-03-30 +# Modified: 2026-03-30 +# ============================================= + +"""Tests for pr_status_sync event handlers.""" + +import pytest +from unittest.mock import MagicMock, patch +from pathlib import Path + + +@pytest.fixture(autouse=True) +def _mock_infrastructure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Mock heavy infrastructure imports.""" + import sys + + mock_config = MagicMock() + mock_config.TRIGGER_ROOT = tmp_path + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.config", mock_config) + + mock_json_handler = MagicMock() + mock_json_handler.log_operation = MagicMock(return_value=True) + json_pkg = MagicMock() + json_pkg.json_handler = mock_json_handler + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.json", json_pkg) + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.json.json_handler", mock_json_handler) + + monkeypatch.delitem(sys.modules, "aipass.trigger.apps.handlers.events.pr_status_sync", raising=False) + + +def _import_module(): + """Import fresh after mocking.""" + import aipass.trigger.apps.handlers.events.pr_status_sync as m + return m + + +class TestHandlePrCreated: + """Tests for handle_pr_created.""" + + @patch("subprocess.Popen") + def test_fires_subprocess(self, mock_popen: MagicMock) -> None: + """Calls drone @prax status sync via Popen.""" + mod = _import_module() + mod.handle_pr_created(branch="flow", pr_url="https://github.com/org/repo/pull/42") + + mock_popen.assert_called_once() + args = mock_popen.call_args[0][0] + assert args == ["drone", "@prax", "status", "sync"] + + @patch("subprocess.Popen") + def test_does_not_block(self, mock_popen: MagicMock) -> None: + """Popen is used (not run), so it doesn't block.""" + mod = _import_module() + mod.handle_pr_created(branch="api") + mock_popen.assert_called_once() + # Popen returns immediately — no .wait() or .communicate() called + mock_popen.return_value.wait.assert_not_called() + + @patch("subprocess.Popen", side_effect=FileNotFoundError("drone not found")) + def test_handles_missing_drone(self, mock_popen: MagicMock, tmp_path: Path) -> None: + """Doesn't crash if drone binary not found.""" + mod = _import_module() + mod.handle_pr_created(branch="flow") # Should not raise + + @patch("subprocess.Popen") + def test_logs_operation(self, mock_popen: MagicMock) -> None: + """Logs the event via json_handler.""" + mod = _import_module() + from aipass.trigger.apps.handlers.json import json_handler + json_handler.log_operation.reset_mock() # type: ignore[union-attr] + + mod.handle_pr_created(branch="spawn", pr_url="https://example.com/pr/1") + + json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr] + "pr_created_event", + {"branch": "spawn", "pr_url": "https://example.com/pr/1"}, + ) + + @patch("subprocess.Popen") + def test_none_defaults(self, mock_popen: MagicMock) -> None: + """Handles None parameters gracefully.""" + mod = _import_module() + mod.handle_pr_created() # All defaults + mock_popen.assert_called_once() + + +class TestHandlePrMerged: + """Tests for handle_pr_merged.""" + + @patch("subprocess.Popen") + def test_fires_subprocess(self, mock_popen: MagicMock) -> None: + """Calls drone @prax status sync via Popen.""" + mod = _import_module() + mod.handle_pr_merged(pr_number="42", title="Fix the thing") + + mock_popen.assert_called_once() + args = mock_popen.call_args[0][0] + assert args == ["drone", "@prax", "status", "sync"] + + @patch("subprocess.Popen", side_effect=OSError("exec failed")) + def test_handles_exec_failure(self, mock_popen: MagicMock) -> None: + """Doesn't crash on subprocess failure.""" + mod = _import_module() + mod.handle_pr_merged(pr_number="99") # Should not raise + + @patch("subprocess.Popen") + def test_logs_operation(self, mock_popen: MagicMock) -> None: + """Logs the event via json_handler.""" + mod = _import_module() + from aipass.trigger.apps.handlers.json import json_handler + json_handler.log_operation.reset_mock() # type: ignore[union-attr] + + mod.handle_pr_merged(pr_number="7", title="Add feature") + + json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr] + "pr_merged_event", + {"pr_number": "7", "title": "Add feature"}, + ) + + @patch("subprocess.Popen") + def test_none_defaults(self, mock_popen: MagicMock) -> None: + """Handles None parameters gracefully.""" + mod = _import_module() + mod.handle_pr_merged() # All defaults + mock_popen.assert_called_once()